├── Example
├── Gemfile
├── Stagehand Tutorial.playground
│ ├── playground.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ ├── Sources
│ │ ├── AnimationFactory.swift
│ │ ├── WrapperView.swift
│ │ ├── ModelDrivenView.swift
│ │ └── RaceCarView.swift
│ ├── contents.xcplayground
│ └── Pages
│ │ ├── Assigning Properties During Animations.xcplaygroundpage
│ │ ├── Sources
│ │ │ └── ExpandedBoundsView.swift
│ │ └── Contents.swift
│ │ ├── Animation Groups.xcplaygroundpage
│ │ └── Contents.swift
│ │ ├── Snapshot Testing Animations.xcplaygroundpage
│ │ └── Contents.swift
│ │ ├── Repeating Animations.xcplaygroundpage
│ │ └── Contents.swift
│ │ ├── Executing Code Every Frame.xcplaygroundpage
│ │ └── Contents.swift
│ │ ├── Creating and Executing an Animation.xcplaygroundpage
│ │ └── Contents.swift
│ │ ├── Animation Curves.xcplaygroundpage
│ │ └── Contents.swift
│ │ ├── Advanced Execution of Animations.xcplaygroundpage
│ │ └── Contents.swift
│ │ ├── All About Keyframes.xcplaygroundpage
│ │ └── Contents.swift
│ │ ├── Animating Custom Properties.xcplaygroundpage
│ │ └── Contents.swift
│ │ └── Composing Animations.xcplaygroundpage
│ │ └── Contents.swift
├── Stagehand.xcodeproj
│ ├── project.xcworkspace
│ │ └── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── xcbaselines
│ │ └── 3D32EF3B23404F6A001144B3.xcbaseline
│ │ ├── 4347C282-2559-4EFF-A169-EC7DCEFA739B.plist
│ │ ├── 6B34E5D7-3D0D-449C-BAAC-3F2D290EF8A9.plist
│ │ └── Info.plist
├── Unit Tests
│ ├── __Snapshots__
│ │ ├── SnapshotTestingAPNGImageTests
│ │ │ ├── testAnimationGroupSnapshot.402x874-18-0-3x.png
│ │ │ ├── testSimpleAnimationSnapshot.402x874-18-0-3x.png
│ │ │ ├── testAnimationGroupSnapshot.start-402x874-18-0-3x.png
│ │ │ ├── testSimpleAnimationSnapshot.start-402x874-18-0-3x.png
│ │ │ ├── testAnimationWithNonViewElementSnapshot.402x874-18-0-3x.png
│ │ │ ├── testAnimationSnapshotWithRepeatingAnimation.402x874-18-0-3x.png
│ │ │ └── testAnimationWithNonViewElementSnapshot.start-402x874-18-0-3x.png
│ │ └── SnapshotTestingFrameImageTests
│ │ │ ├── testAnimationGroupSnapshot.end-402x874-18-0-3x.png
│ │ │ ├── testSimpleAnimationSnapshot.end-402x874-18-0-3x.png
│ │ │ ├── testAnimationGroupSnapshot.middle-402x874-18-0-3x.png
│ │ │ ├── testAnimationGroupSnapshot.start-402x874-18-0-3x.png
│ │ │ ├── testSimpleAnimationSnapshot.start-402x874-18-0-3x.png
│ │ │ ├── testSimpleAnimationSnapshot.middle-402x874-18-0-3x.png
│ │ │ ├── testAnimationWithNonViewElementSnapshot.end-402x874-18-0-3x.png
│ │ │ ├── testAnimationWithNonViewElementSnapshot.middle-402x874-18-0-3x.png
│ │ │ └── testAnimationWithNonViewElementSnapshot.start-402x874-18-0-3x.png
│ ├── ReferenceImages
│ │ └── _64
│ │ │ ├── Stagehand_UnitTests.AnimationCurveSnapshotTests
│ │ │ ├── testLinear_18_0_402x874@3x.png
│ │ │ ├── testParabolicEaseIn_18_0_402x874@3x.png
│ │ │ ├── testParabolicEaseOut_18_0_402x874@3x.png
│ │ │ ├── testCubicBezierEaseIn_18_0_402x874@3x.png
│ │ │ ├── testCubicBezierEaseOut_18_0_402x874@3x.png
│ │ │ ├── testCubicBezierOvershoot_18_0_402x874@3x.png
│ │ │ ├── testCubicBezierEaseInEaseOut_18_0_402x874@3x.png
│ │ │ └── testSinusoidalEaseInEaseOut_18_0_402x874@3x.png
│ │ │ ├── Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests
│ │ │ ├── testScale_18_0_402x874@3x.png
│ │ │ ├── testShear_18_0_402x874@3x.png
│ │ │ ├── testRotation_18_0_402x874@3x.png
│ │ │ ├── testZeroScale_18_0_402x874@3x.png
│ │ │ ├── testPerspective_18_0_402x874@3x.png
│ │ │ ├── testScaleAndRotation_18_0_402x874@3x.png
│ │ │ ├── testRotationAcrossBoundary_18_0_402x874@3x.png
│ │ │ └── testScaleAndRotationWithPerspective_18_0_402x874@3x.png
│ │ │ ├── Stagehand_UnitTests.AnimationSnapshotTests
│ │ │ ├── testLongAnimationSnapshotAPNG_18_0_402x874@3x.png
│ │ │ ├── testSimpleAnimationSnapshotAPNG_18_0_402x874@3x.png
│ │ │ ├── testSimpleAnimationSnapshot_end_18_0_402x874@3x.png
│ │ │ ├── testSimpleAnimationSnapshot_start_18_0_402x874@3x.png
│ │ │ ├── testSimpleAnimationSnapshot_middle_18_0_402x874@3x.png
│ │ │ ├── testAnimationWithPartialTransparency_18_0_402x874@3x.png
│ │ │ ├── testAutoreversingAnimationSnapshotAPNG_18_0_402x874@3x.png
│ │ │ ├── testSimpleAnimationSnapshotAPNGAtHighFPS_18_0_402x874@3x.png
│ │ │ ├── testAnimationWithNonViewElementSnapshotAPNG_18_0_402x874@3x.png
│ │ │ ├── testAnimationWithNonViewElementSnapshot_end_18_0_402x874@3x.png
│ │ │ ├── testAnimationWithExecutionBlocksSnapshotAPNG_18_0_402x874@3x.png
│ │ │ ├── testAnimationWithExecutionBlocksSnapshot_end_18_0_402x874@3x.png
│ │ │ ├── testAnimationWithNonViewElementSnapshot_start_18_0_402x874@3x.png
│ │ │ ├── testAnimationWithExecutionBlocksSnapshot_middle_18_0_402x874@3x.png
│ │ │ ├── testAnimationWithExecutionBlocksSnapshot_start_18_0_402x874@3x.png
│ │ │ ├── testAnimationWithNonViewElementSnapshot_middle_18_0_402x874@3x.png
│ │ │ └── testAutoreversingAnimationWithExecutionBlocksSnapshotAPNG_18_0_402x874@3x.png
│ │ │ └── Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests
│ │ │ ├── testScale_18_0_402x874@3x.png
│ │ │ ├── testRotation_18_0_402x874@3x.png
│ │ │ ├── testTranslation_18_0_402x874@3x.png
│ │ │ ├── testSkewedTransforms_18_0_402x874@3x.png
│ │ │ ├── testMultipleFactorTransforms_18_0_402x874@3x.png
│ │ │ ├── testTranslatingSkewedTransforms_18_0_402x874@3x.png
│ │ │ └── testFlippedScaleResultsInRotation_18_0_402x874@3x.png
│ ├── Info.plist
│ ├── TestDriver.swift
│ ├── CATransform3D+Additions.swift
│ ├── AnimatableContainerView.swift
│ ├── SnapshotTestCase.swift
│ ├── QuadrantView.swift
│ ├── CGAffineTransformInterpolationTests.swift
│ ├── AnimationCurveSnapshotTests.swift
│ ├── SnapshotTestingAPNGImageTests.swift
│ └── SnapshotTestingFrameImageTests.swift
├── Stagehand.xcworkspace
│ ├── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── contents.xcworkspacedata
├── Podfile
├── Performance Tests
│ ├── Info.plist
│ ├── AnimationCurvePerformanceTests.swift
│ └── TransformPerformanceTests.swift
├── Stagehand
│ ├── Images.xcassets
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Info.plist
│ ├── AppDelegate.swift
│ ├── ShapeLayerUtils.swift
│ ├── SimpleAnimationsViewController.swift
│ ├── Base.lproj
│ │ └── LaunchScreen.xib
│ ├── AnimationCancelationViewController.swift
│ ├── AnimationGroupViewController.swift
│ ├── DemoViewController.swift
│ ├── ChildAnimationsWithCurvesViewController.swift
│ ├── AnimationQueueViewController.swift
│ ├── AnimationFactory.swift
│ ├── ExecutionBlockViewController.swift
│ ├── ColorAnimationsViewController.swift
│ └── RootViewController.swift
├── Podfile.lock
└── Gemfile.lock
├── Package.resolved
├── .gitignore
├── Sources
├── Info.plist
├── Stagehand
│ ├── Utilities
│ │ ├── ClosedRange+Additions.swift
│ │ ├── Comparable+Additions.swift
│ │ ├── Vector4.swift
│ │ ├── Vector3.swift
│ │ └── Quaternions.swift
│ ├── Driver
│ │ ├── DisplayLinkDriver+Dependencies.swift
│ │ └── Driver.swift
│ ├── AnimatableProperty
│ │ ├── AnimatableProperty+FloatingPoint.swift
│ │ ├── AnimatableProperty.swift
│ │ ├── AnimatableProperty+CoreGraphics.swift
│ │ └── AnimatableProperty+Optional.swift
│ ├── AnimationCurve
│ │ ├── AnimationCurve.swift
│ │ └── AnimationCurve+Basic.swift
│ ├── AnimationInstance
│ │ └── Renderer.swift
│ └── AnimationQueue.swift
└── StagehandTesting
│ └── Core
│ ├── NoOpDriver.swift
│ └── SnapshotTestDriver.swift
├── Stagehand.podspec
├── CONTRIBUTING.md
├── StagehandTesting.podspec
├── Package.swift
├── Scripts
└── rename-snapshots.swift
├── .github
└── workflows
│ └── ci.yml
└── README.md
/Example/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org' do
2 | gem 'cocoapods', '~> 1.14'
3 | end
4 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/playground.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Stagehand.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testAnimationGroupSnapshot.402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testAnimationGroupSnapshot.402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testSimpleAnimationSnapshot.402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testSimpleAnimationSnapshot.402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testAnimationGroupSnapshot.start-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testAnimationGroupSnapshot.start-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationGroupSnapshot.end-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationGroupSnapshot.end-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testSimpleAnimationSnapshot.end-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testSimpleAnimationSnapshot.end-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testLinear_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testLinear_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testSimpleAnimationSnapshot.start-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testSimpleAnimationSnapshot.start-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationGroupSnapshot.middle-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationGroupSnapshot.middle-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationGroupSnapshot.start-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationGroupSnapshot.start-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testSimpleAnimationSnapshot.start-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testSimpleAnimationSnapshot.start-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testSimpleAnimationSnapshot.middle-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testSimpleAnimationSnapshot.middle-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testAnimationWithNonViewElementSnapshot.402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testAnimationWithNonViewElementSnapshot.402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testParabolicEaseIn_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testParabolicEaseIn_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testParabolicEaseOut_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testParabolicEaseOut_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testAnimationSnapshotWithRepeatingAnimation.402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testAnimationSnapshotWithRepeatingAnimation.402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationWithNonViewElementSnapshot.end-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationWithNonViewElementSnapshot.end-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testCubicBezierEaseIn_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testCubicBezierEaseIn_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testCubicBezierEaseOut_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testCubicBezierEaseOut_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testScale_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testScale_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testShear_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testShear_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testAnimationWithNonViewElementSnapshot.start-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingAPNGImageTests/testAnimationWithNonViewElementSnapshot.start-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationWithNonViewElementSnapshot.middle-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationWithNonViewElementSnapshot.middle-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationWithNonViewElementSnapshot.start-402x874-18-0-3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/__Snapshots__/SnapshotTestingFrameImageTests/testAnimationWithNonViewElementSnapshot.start-402x874-18-0-3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testCubicBezierOvershoot_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testCubicBezierOvershoot_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testLongAnimationSnapshotAPNG_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testLongAnimationSnapshotAPNG_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testRotation_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testRotation_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testZeroScale_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testZeroScale_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testScale_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testScale_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testCubicBezierEaseInEaseOut_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testCubicBezierEaseInEaseOut_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testSinusoidalEaseInEaseOut_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationCurveSnapshotTests/testSinusoidalEaseInEaseOut_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshotAPNG_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshotAPNG_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_end_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_end_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_start_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_start_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testPerspective_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testPerspective_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testRotation_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testRotation_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_middle_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_middle_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithPartialTransparency_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithPartialTransparency_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAutoreversingAnimationSnapshotAPNG_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAutoreversingAnimationSnapshotAPNG_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testScaleAndRotation_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testScaleAndRotation_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testTranslation_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testTranslation_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Stagehand.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshotAPNGAtHighFPS_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshotAPNGAtHighFPS_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithNonViewElementSnapshotAPNG_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithNonViewElementSnapshotAPNG_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithNonViewElementSnapshot_end_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithNonViewElementSnapshot_end_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testRotationAcrossBoundary_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testRotationAcrossBoundary_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testSkewedTransforms_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testSkewedTransforms_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshotAPNG_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshotAPNG_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_end_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_end_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithNonViewElementSnapshot_start_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithNonViewElementSnapshot_start_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_middle_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_middle_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_start_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_start_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithNonViewElementSnapshot_middle_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithNonViewElementSnapshot_middle_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testMultipleFactorTransforms_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testMultipleFactorTransforms_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testScaleAndRotationWithPerspective_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CATransform3DInterpolationSnapshotTests/testScaleAndRotationWithPerspective_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testTranslatingSkewedTransforms_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testTranslatingSkewedTransforms_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/playground.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testFlippedScaleResultsInRotation_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.CGAffineTransformInterpolationSnapshotTests/testFlippedScaleResultsInRotation_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAutoreversingAnimationWithExecutionBlocksSnapshotAPNG_18_0_402x874@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cashapp/stagehand/HEAD/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAutoreversingAnimationWithExecutionBlocksSnapshotAPNG_18_0_402x874@3x.png
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-snapshot-testing",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git",
7 | "state" : {
8 | "revision" : "5c3d2141fb0e55da411577012c917962b6b3517e",
9 | "version" : "1.8.0"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/Example/Stagehand.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OS X
2 | .DS_Store
3 |
4 | # Xcode
5 | build/
6 | *.pbxuser
7 | !default.pbxuser
8 | *.mode1v3
9 | !default.mode1v3
10 | *.mode2v3
11 | !default.mode2v3
12 | *.perspectivev3
13 | !default.perspectivev3
14 | xcuserdata/
15 | *.xccheckout
16 | profile
17 | *.moved-aside
18 | DerivedData
19 | *.hmap
20 | *.ipa
21 |
22 | # Bundler
23 | .bundle
24 |
25 | # Carthage
26 | Carthage/Build
27 |
28 | # CocoaPods
29 | Pods/
30 |
31 | # Swift Playgrounds
32 | timeline.xctimeline
33 |
34 | # Swift Pacakge Manager
35 | generated/
36 | .build/
37 | .swiftpm/
38 |
39 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Sources/AnimationFactory.swift:
--------------------------------------------------------------------------------
1 | import Stagehand
2 |
3 | public enum AnimationFactory {
4 |
5 | public static func makeBasicViewAnimation() -> Animation {
6 | var animation = Animation()
7 |
8 | animation.addKeyframe(for: \.alpha, at: 0, value: 1)
9 | animation.addKeyframe(for: \.alpha, at: 0.5, value: 0.5)
10 | animation.addKeyframe(for: \.alpha, at: 1, value: 1)
11 |
12 | animation.addKeyframe(for: \.transform, at: 0, value: .identity)
13 | animation.addKeyframe(for: \.transform, at: 0.5, value: .init(scaleX: 1.1, y: 1.1))
14 | animation.addKeyframe(for: \.transform, at: 1, value: .identity)
15 |
16 | return animation
17 | }
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/Example/Podfile:
--------------------------------------------------------------------------------
1 | use_frameworks!
2 |
3 | platform :ios, '12.0'
4 |
5 | target 'Stagehand_Example' do
6 | pod 'Stagehand', :path => '../'
7 |
8 | target 'Stagehand-UnitTests' do
9 | inherit! :search_paths
10 |
11 | pod 'StagehandTesting/iOSSnapshotTestCase', :path => '../'
12 | pod 'StagehandTesting/SnapshotTesting', :path => '../'
13 |
14 | # SnapshotTesting dropped support for building with Xcode 10 in 1.8.0, so pin the version to 1.7.0 in order to
15 | # run our tests against Xcode 10.
16 | pod 'SnapshotTesting', '= 1.7.0'
17 | end
18 |
19 | target 'Stagehand-PerformanceTests' do
20 | inherit! :search_paths
21 |
22 | pod 'Stagehand', :path => '../'
23 | end
24 | end
25 |
26 | install! 'cocoapods', disable_input_output_paths: true
27 |
--------------------------------------------------------------------------------
/Example/Performance Tests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Sources/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | FMWK
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1.0
21 | NSPrincipalClass
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Example/Unit Tests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/contents.xcplayground:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/Stagehand.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'Stagehand'
3 | s.version = '4.0.0'
4 | s.summary = 'Modern, type-safe API for building animations on iOS'
5 | s.homepage = 'https://github.com/CashApp/Stagehand'
6 | s.license = { :type => 'Apache License, Version 2.0', :file => 'LICENSE' }
7 | s.author = 'Square'
8 | s.source = { :git => 'https://github.com/CashApp/Stagehand.git', :tag => s.version.to_s }
9 |
10 | s.ios.deployment_target = '12.0'
11 |
12 | s.swift_version = '5.0.1'
13 |
14 | s.source_files = 'Sources/Stagehand/**/*'
15 |
16 | s.frameworks = 'CoreGraphics', 'UIKit'
17 |
18 | # In order for StagehandTesting to publish correctly, we need to allow Stagehand to be accessible
19 | # using `@testable import`. This allows StagehandTesting to build using a RELEASE config.
20 | s.pod_target_xcconfig = {
21 | 'ENABLE_TESTABILITY' => 'YES'
22 | }
23 | end
24 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Assigning Properties During Animations.xcplaygroundpage/Sources/ExpandedBoundsView.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public final class ExpandedBoundsView: UIView {
4 |
5 | // MARK: - Life Cycle
6 |
7 | public override init(frame: CGRect) {
8 | super.init(frame: frame)
9 |
10 | backgroundColor = .red
11 |
12 | bigSubview.backgroundColor = .green
13 | addSubview(bigSubview)
14 | }
15 |
16 | @available(*, unavailable)
17 | public required init?(coder: NSCoder) {
18 | fatalError("init(coder:) has not been implemented")
19 | }
20 |
21 | // MARK: - Private Properties
22 |
23 | private let bigSubview: UIView = .init()
24 |
25 | // MARK: - UIView
26 |
27 | public override func layoutSubviews() {
28 | bigSubview.bounds.size = bounds.insetBy(dx: -20, dy: 10).size
29 | bigSubview.center = .init(x: bounds.midX, y: bounds.midY)
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/Stagehand/Utilities/ClosedRange+Additions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | extension ClosedRange {
20 |
21 | internal init(unorderedBounds bounds: (Bound, Bound)) {
22 | if bounds.0 < bounds.1 {
23 | self = (bounds.0...bounds.1)
24 | } else {
25 | self = (bounds.1...bounds.0)
26 | }
27 | }
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Sources/WrapperView.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public final class WrapperView: UIView {
4 |
5 | // MARK: - Life Cycle
6 |
7 | public init(wrappedView: UIView, outset: CGFloat = 20) {
8 | self.wrappedView = wrappedView
9 |
10 | super.init(
11 | frame: .init(
12 | x: 0,
13 | y: 0,
14 | width: wrappedView.bounds.width + 2 * outset,
15 | height: wrappedView.bounds.height + 2 * outset
16 | )
17 | )
18 |
19 | addSubview(wrappedView)
20 | }
21 |
22 | @available(*, unavailable)
23 | public required init?(coder: NSCoder) {
24 | fatalError("init(coder:) has not been implemented")
25 | }
26 |
27 | // MARK: - Private Properties
28 |
29 | private let wrappedView: UIView
30 |
31 | // MARK: - UIView
32 |
33 | public override func layoutSubviews() {
34 | wrappedView.center = .init(x: bounds.midX, y: bounds.midY)
35 | }
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/Stagehand/Utilities/Comparable+Additions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | extension Comparable {
20 |
21 | func clamped(min minValue: Self, max maxValue: Self) -> Self {
22 | return max(minValue, min(maxValue, self))
23 | }
24 |
25 | func clamped(in range: ClosedRange) -> Self {
26 | return clamped(min: range.lowerBound, max: range.upperBound)
27 | }
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/StagehandTesting/Core/NoOpDriver.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | @testable import Stagehand
20 |
21 | final class NoOpDriver: Driver {
22 |
23 | // MARK: - Driver
24 |
25 | weak var animationInstance: DrivenAnimationInstance!
26 |
27 | func animationInstanceDidInitialize() {
28 | // No-op.
29 | }
30 |
31 | func animationInstanceDidCancel(behavior: AnimationInstance.CancelationBehavior) {
32 | // No-op.
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/Example/Stagehand/Images.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "20x20",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "20x20",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "29x29",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "29x29",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "40x40",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "40x40",
31 | "scale" : "3x"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "size" : "60x60",
36 | "scale" : "2x"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "size" : "60x60",
41 | "scale" : "3x"
42 | },
43 | {
44 | "idiom" : "ios-marketing",
45 | "size" : "1024x1024",
46 | "scale" : "1x"
47 | }
48 | ],
49 | "info" : {
50 | "version" : 1,
51 | "author" : "xcode"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Sources/Stagehand/Driver/DisplayLinkDriver+Dependencies.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import QuartzCore
18 |
19 | internal typealias DisplayLinkFactory = (_ target: Any, _ selector: Selector) -> DisplayLinkDriverDisplayLink
20 |
21 | internal protocol DisplayLinkDriverDisplayLink: AnyObject {
22 |
23 | var timestamp: CFTimeInterval { get }
24 |
25 | func add(to runloop: RunLoop, forMode mode: RunLoop.Mode)
26 |
27 | func invalidate()
28 |
29 | }
30 |
31 | extension CADisplayLink: DisplayLinkDriverDisplayLink {}
32 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Sources/ModelDrivenView.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public final class ModelDrivenView: UIView {
4 |
5 | // MARK: - Life Cycle
6 |
7 | public override init(frame: CGRect) {
8 | super.init(frame: frame)
9 |
10 | backgroundColor = .red
11 | }
12 |
13 | @available(*, unavailable)
14 | required init?(coder: NSCoder) {
15 | fatalError("init(coder:) has not been implemented")
16 | }
17 |
18 | // MARK: - Public Types
19 |
20 | public struct Model {
21 |
22 | // MARK: - Life Cycle
23 |
24 | public init(
25 | backgroundColor: UIColor?
26 | ) {
27 | self.backgroundColor = backgroundColor
28 | }
29 |
30 | // MARK: - Public Properties
31 |
32 | public var backgroundColor: UIColor?
33 |
34 | }
35 |
36 | // MARK: - Public Properties
37 |
38 | public var currentModel: Model {
39 | return .init(
40 | backgroundColor: backgroundColor
41 | )
42 | }
43 |
44 | // MARK: - Public Methods
45 |
46 | public func apply(model: Model) {
47 | backgroundColor = model.backgroundColor
48 | }
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/Stagehand/AnimatableProperty/AnimatableProperty+FloatingPoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | extension Float: AnimatableProperty {
20 |
21 | public static func value(between initialValue: Float, and finalValue: Float, at progress: Double) -> Float {
22 | return initialValue + Float(progress) * (finalValue - initialValue)
23 | }
24 |
25 | }
26 |
27 | extension Double: AnimatableProperty {
28 |
29 | public static func value(between initialValue: Double, and finalValue: Double, at progress: Double) -> Double {
30 | return initialValue + progress * (finalValue - initialValue)
31 | }
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/Example/Podfile.lock:
--------------------------------------------------------------------------------
1 | PODS:
2 | - iOSSnapshotTestCase (6.2.0):
3 | - iOSSnapshotTestCase/SwiftSupport (= 6.2.0)
4 | - iOSSnapshotTestCase/Core (6.2.0)
5 | - iOSSnapshotTestCase/SwiftSupport (6.2.0):
6 | - iOSSnapshotTestCase/Core
7 | - SnapshotTesting (1.7.0)
8 | - Stagehand (4.0.0)
9 | - StagehandTesting/iOSSnapshotTestCase (4.0.0):
10 | - iOSSnapshotTestCase (~> 6.1)
11 | - Stagehand (= 4.0.0)
12 | - StagehandTesting/SnapshotTesting (4.0.0):
13 | - SnapshotTesting (~> 1.7)
14 | - Stagehand (= 4.0.0)
15 |
16 | DEPENDENCIES:
17 | - SnapshotTesting (= 1.7.0)
18 | - Stagehand (from `../`)
19 | - StagehandTesting/iOSSnapshotTestCase (from `../`)
20 | - StagehandTesting/SnapshotTesting (from `../`)
21 |
22 | SPEC REPOS:
23 | trunk:
24 | - iOSSnapshotTestCase
25 | - SnapshotTesting
26 |
27 | EXTERNAL SOURCES:
28 | Stagehand:
29 | :path: "../"
30 | StagehandTesting:
31 | :path: "../"
32 |
33 | SPEC CHECKSUMS:
34 | iOSSnapshotTestCase: 9ab44cb5aa62b84d31847f40680112e15ec579a6
35 | SnapshotTesting: 273b614fcc60fac7d9f613f6648afa91a7da36be
36 | Stagehand: 29ee26a0690ebf90a5ea45e86fd88b865baf5403
37 | StagehandTesting: 3354a5300e7fc6d1ba9ab182762feff9e80cb6de
38 |
39 | PODFILE CHECKSUM: b4841bd82e57283ff97d83f4bb89137cc01f6102
40 |
41 | COCOAPODS: 1.14.3
42 |
--------------------------------------------------------------------------------
/Example/Stagehand/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1
23 | LSRequiresIPhoneOS
24 |
25 | UILaunchStoryboardName
26 | LaunchScreen
27 | UIRequiredDeviceCapabilities
28 |
29 | armv7
30 |
31 | CFBundleDisplayName
32 | Stagehand
33 | UISupportedInterfaceOrientations
34 |
35 | UIInterfaceOrientationPortrait
36 | UIInterfaceOrientationLandscapeLeft
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Sources/RaceCarView.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public final class RaceCarView: UIView {
4 |
5 | // MARK: - Life Cycle
6 |
7 | public override init(frame: CGRect) {
8 | super.init(frame: frame)
9 |
10 | topView.backgroundColor = .red
11 | addSubview(topView)
12 |
13 | bottomView.backgroundColor = .yellow
14 | addSubview(bottomView)
15 | }
16 |
17 | @available(*, unavailable)
18 | public required init?(coder: NSCoder) {
19 | fatalError("init(coder:) has not been implemented")
20 | }
21 |
22 | // MARK: - Public Properties
23 |
24 | public var topView: UIView = .init()
25 |
26 | public var bottomView: UIView = .init()
27 |
28 | // MARK: - UIView
29 |
30 | public override func layoutSubviews() {
31 | let subviewSize = bounds.height / 4
32 |
33 | topView.bounds.size = .init(width: subviewSize, height: subviewSize)
34 | topView.center = .init(
35 | x: bounds.minX + subviewSize,
36 | y: bounds.height / 3
37 | )
38 |
39 | bottomView.bounds.size = .init(width: subviewSize, height: subviewSize)
40 | bottomView.center = .init(
41 | x: bounds.minX + subviewSize,
42 | y: bounds.height * 2 / 3
43 | )
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ### Sign the CLA
2 |
3 | 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.
4 |
5 | ### One Issue per Pull Request
6 |
7 | Keep your Pull Requests small. Small PRs are easier to reason about which makes them significantly more likely to get merged.
8 |
9 | ### Issues Before Features
10 |
11 | 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.
12 |
13 | ### Backwards Compatibility
14 |
15 | 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.
16 |
17 | ### Forwards Compatibility
18 |
19 | Please do not write new code using deprecated APIs.
20 |
21 | ### Keep the Demo App and Documentation Updated
22 |
23 | 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.
24 |
--------------------------------------------------------------------------------
/Example/Stagehand/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import UIKit
18 |
19 | @UIApplicationMain
20 | final class AppDelegate: UIResponder, UIApplicationDelegate {
21 |
22 | var window: UIWindow?
23 |
24 | func application(
25 | _ application: UIApplication,
26 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
27 | ) -> Bool {
28 | let window = UIWindow(frame: UIScreen.main.bounds)
29 | self.window = window
30 |
31 | let rootViewController = RootViewController()
32 |
33 | let navigationController = UINavigationController(rootViewController: rootViewController)
34 | window.rootViewController = navigationController
35 |
36 | window.makeKeyAndVisible()
37 |
38 | return true
39 | }
40 |
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/Sources/Stagehand/AnimatableProperty/AnimatableProperty.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | /// Defines the interface of a type for which the value can be animated. More specifically, interpolates between two
20 | /// values (the `initialValue` and `finalValue`) at a given `progress` in the range `[0,1]`.
21 | public protocol AnimatableProperty {
22 |
23 | /// Returns an interpolation between the `initialValue` and `finalValue` at the given `progress` in the range.
24 | ///
25 | /// - parameter initialValue: The initial value of the interpolation, i.e. the value when the `progress` is 0.
26 | /// - parameter finalValue: The final value of the interpolation, i.e. the value when the `progress` is 1.
27 | /// - parameter progress: The progress along the interpolation, in the range `[0,1]`.
28 | static func value(between initialValue: Self, and finalValue: Self, at progress: Double) -> Self
29 |
30 | }
31 |
--------------------------------------------------------------------------------
/StagehandTesting.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'StagehandTesting'
3 | s.version = '4.0.0'
4 | s.summary = 'Utilities for snapshot testing animations created using the Stagehand framework'
5 | s.homepage = 'https://github.com/CashApp/Stagehand'
6 | s.license = { :type => 'Apache License, Version 2.0', :file => 'LICENSE' }
7 | s.author = 'Square'
8 | s.source = { :git => 'https://github.com/CashApp/Stagehand.git', :tag => s.version.to_s }
9 |
10 | s.ios.deployment_target = '12.0'
11 |
12 | s.swift_version = '5.0.1'
13 |
14 | # The dependency on Stagehand is pinned to the same version as StagehandTesting. This is because
15 | # StagehandTesting depends on internal methods inside Stagehand, so the normal rules of semantic
16 | # versioning don't apply.
17 | s.dependency 'Stagehand', s.version.to_s
18 |
19 | s.default_subspec = 'SnapshotTesting'
20 |
21 | s.subspec 'iOSSnapshotTestCase' do |ss|
22 | ss.source_files = [
23 | 'Sources/StagehandTesting/Core/**/*.swift',
24 | 'Sources/StagehandTesting/iOSSnapshotTestCase/**/*.swift',
25 | ]
26 |
27 | ss.dependency 'iOSSnapshotTestCase', '~> 6.1'
28 | end
29 |
30 | s.subspec 'SnapshotTesting' do |ss|
31 | ss.source_files = [
32 | 'Sources/StagehandTesting/Core/**/*.swift',
33 | 'Sources/StagehandTesting/SnapshotTesting/**/*.swift',
34 | ]
35 |
36 | ss.dependency 'SnapshotTesting', '~> 1.7'
37 | end
38 |
39 | s.frameworks = 'XCTest'
40 | s.weak_framework = 'XCTest'
41 | end
42 |
--------------------------------------------------------------------------------
/Sources/Stagehand/Utilities/Vector4.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import CoreGraphics
18 | import QuartzCore
19 |
20 | internal struct Vector4 {
21 |
22 | // MARK: - Internal Properties
23 |
24 | var v1: CGFloat
25 |
26 | var v2: CGFloat
27 |
28 | var v3: CGFloat
29 |
30 | var v4: CGFloat
31 |
32 | // MARK: - Operators
33 |
34 | static func * (vector: Vector4, matrix: CATransform3D) -> Vector4 {
35 | return Vector4(
36 | v1: vector.v1 * matrix.m11 + vector.v2 * matrix.m21 + vector.v3 * matrix.m31 + vector.v4 * matrix.m41,
37 | v2: vector.v1 * matrix.m12 + vector.v2 * matrix.m22 + vector.v3 * matrix.m32 + vector.v4 * matrix.m42,
38 | v3: vector.v1 * matrix.m13 + vector.v2 * matrix.m23 + vector.v3 * matrix.m33 + vector.v4 * matrix.m43,
39 | v4: vector.v1 * matrix.m14 + vector.v2 * matrix.m24 + vector.v3 * matrix.m34 + vector.v4 * matrix.m44
40 | )
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.8
2 |
3 | //
4 | // Copyright 2020 Square Inc.
5 | //
6 | // Licensed under the Apache License, Version 2.0 (the "License");
7 | // you may not use this file except in compliance with the License.
8 | // You may obtain a copy of the License at
9 | //
10 | // http://www.apache.org/licenses/LICENSE-2.0
11 | //
12 | // Unless required by applicable law or agreed to in writing, software
13 | // distributed under the License is distributed on an "AS IS" BASIS,
14 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | // See the License for the specific language governing permissions and
16 | // limitations under the License.
17 | //
18 |
19 | import PackageDescription
20 |
21 | let package = Package(
22 | name: "Stagehand",
23 | platforms: [
24 | .iOS(.v12),
25 | .macOS(.v11),
26 | ],
27 | products: [
28 | .library(
29 | name: "Stagehand",
30 | targets: ["Stagehand"]
31 | ),
32 | .library(
33 | name: "StagehandTesting",
34 | targets: ["StagehandTesting"]
35 | ),
36 | ],
37 | dependencies: [
38 | .package(
39 | url: "https://github.com/pointfreeco/swift-snapshot-testing.git",
40 | .upToNextMajor(from: "1.8.0")
41 | ),
42 | ],
43 | targets: [
44 | .target(
45 | name: "Stagehand",
46 | dependencies: []
47 | ),
48 | .target(
49 | name: "StagehandTesting",
50 | dependencies: [
51 | "Stagehand",
52 | .product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
53 | ],
54 | exclude: ["iOSSnapshotTestCase"]
55 | ),
56 | ],
57 | swiftLanguageVersions: [.v5]
58 | )
59 |
60 | let version = Version(4, 0, 0)
61 |
--------------------------------------------------------------------------------
/Example/Stagehand/ShapeLayerUtils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import UIKit
18 |
19 | enum ShapeLayerUtils {
20 |
21 | /// Sets the layer's path to a stroked grid with the given number of rows and columns
22 | static func addGridPath(to layer: CAShapeLayer, rows: Int, columns: Int) {
23 | let gridPath: UIBezierPath = .init()
24 | let cellSize: CGSize = .init(
25 | width: layer.bounds.width / CGFloat(columns),
26 | height: layer.bounds.height / CGFloat(rows)
27 | )
28 | for row in 0...rows {
29 | gridPath.move(to: .init(x: 0, y: cellSize.height * CGFloat(row)))
30 | gridPath.addLine(to: .init(x: layer.bounds.width, y: cellSize.height * CGFloat(row)))
31 | }
32 | for column in 0...columns {
33 | gridPath.move(to: .init(x: cellSize.width * CGFloat(column), y: 0))
34 | gridPath.addLine(to: .init(x: cellSize.width * CGFloat(column), y: layer.bounds.height))
35 | }
36 | layer.path = gridPath.cgPath
37 | }
38 |
39 | }
40 |
--------------------------------------------------------------------------------
/Example/Unit Tests/TestDriver.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | @testable import Stagehand
18 |
19 | final class TestDriver: Driver {
20 |
21 | // MARK: - Private Properties
22 |
23 | private var renderedRelativeTimestamp: Double?
24 |
25 | // MARK: - Driver
26 |
27 | weak var animationInstance: DrivenAnimationInstance!
28 |
29 | func animationInstanceDidInitialize() {
30 | // No-op.
31 | }
32 |
33 | func animationInstanceDidCancel(behavior: AnimationInstance.CancelationBehavior) {
34 | // No-op.
35 | }
36 |
37 | // MARK: - Public Methods
38 |
39 | func runForward(to relativeTimestamp: Double) {
40 | if let lastRenderedTimestamp = renderedRelativeTimestamp {
41 | animationInstance.executeBlocks(from: lastRenderedTimestamp, .exclusive, to: relativeTimestamp)
42 | } else {
43 | animationInstance.executeBlocks(from: 0, .inclusive, to: relativeTimestamp)
44 | }
45 |
46 | animationInstance.renderFrame(at: relativeTimestamp)
47 |
48 | renderedRelativeTimestamp = relativeTimestamp
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/Sources/StagehandTesting/Core/SnapshotTestDriver.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | @testable import Stagehand
20 |
21 | final class SnapshotTestDriver: Driver {
22 |
23 | // MARK: - Life Cycle
24 |
25 | init(
26 | relativeTimestamp: Double
27 | ) {
28 | self.relativeTimestamp = relativeTimestamp
29 | }
30 |
31 | // MARK: - Private Properties
32 |
33 | private let relativeTimestamp: Double
34 |
35 | // MARK: - Driver
36 |
37 | weak var animationInstance: DrivenAnimationInstance!
38 |
39 | func animationInstanceDidInitialize() {
40 | animationInstance.executeBlocks(from: 0, .inclusive, to: relativeTimestamp)
41 | animationInstance.renderFrame(at: relativeTimestamp)
42 | }
43 |
44 | func animationInstanceDidCancel(behavior: AnimationInstance.CancelationBehavior) {
45 | if relativeTimestamp < 1 {
46 | animationInstance.executeBlocks(from: relativeTimestamp, .exclusive, to: 1)
47 | }
48 | animationInstance.executeBlocks(from: 1, .inclusive, to: 0)
49 |
50 | animationInstance.renderFrame(at: 0)
51 | }
52 |
53 | }
54 |
--------------------------------------------------------------------------------
/Example/Stagehand.xcodeproj/xcshareddata/xcbaselines/3D32EF3B23404F6A001144B3.xcbaseline/4347C282-2559-4EFF-A169-EC7DCEFA739B.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | classNames
6 |
7 | AnimationCurvePerformanceTests
8 |
9 | testCubicBezierEaseInPerformance()
10 |
11 | com.apple.XCTPerformanceMetric_WallClockTime
12 |
13 | baselineAverage
14 | 0.026842
15 | baselineIntegrationDisplayName
16 | Sep 28, 2019 at 7:59:07 PM
17 |
18 |
19 | testParabolicEaseInPerformance()
20 |
21 | com.apple.XCTPerformanceMetric_WallClockTime
22 |
23 | baselineAverage
24 | 0.04231
25 | baselineIntegrationDisplayName
26 | Sep 28, 2019 at 8:08:35 PM
27 |
28 |
29 | testParabolicEaseOutPerformance()
30 |
31 | com.apple.XCTPerformanceMetric_WallClockTime
32 |
33 | baselineAverage
34 | 0.043876
35 | baselineIntegrationDisplayName
36 | Sep 28, 2019 at 8:08:35 PM
37 |
38 |
39 | testSinusoidalEaseInEaseOutPerformance()
40 |
41 | com.apple.XCTPerformanceMetric_WallClockTime
42 |
43 | baselineAverage
44 | 0.020915
45 | baselineIntegrationDisplayName
46 | Sep 28, 2019 at 8:08:35 PM
47 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Assigning Properties During Animations.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | import PlaygroundSupport
4 |
5 | /*:
6 |
7 | # Assigning Properties During Animations
8 |
9 | A special case of executing code during an animation involves assigning a value to a property. As an example, let's say
10 | we want to change the value of a view's `clipsToBounds` property at the half way point of our animation. Using
11 | execution blocks, our animation might look something like this:
12 |
13 | */
14 |
15 | import Stagehand
16 |
17 | var executionBlockAnimation = Animation()
18 |
19 | executionBlockAnimation.addExecution(
20 | onForward: {
21 | $0.clipsToBounds = true
22 | },
23 | at: 0.5
24 | )
25 |
26 | /*:
27 |
28 | This works fine if our animation only runs in the forward direction, but what if we need to handle the reverse case?
29 | Luckily, this is already handled by property assignments.
30 |
31 | */
32 |
33 | var propertyAssignmentAnimation = Animation()
34 |
35 | propertyAssignmentAnimation.addAssignment(for: \.clipsToBounds, at: 0.5, value: true)
36 |
37 | /*:
38 |
39 | Like the execution block above, this will set the value of `clipsToBounds` to be `true` half way through the animation.
40 | When run in reverse, the property assignment will restore `clipsToBounds` to its original value for when the property
41 | was assigned.
42 |
43 | */
44 |
45 | propertyAssignmentAnimation.implicitRepeatStyle = .infinitelyRepeating(autoreversing: true)
46 |
47 | let view = ExpandedBoundsView(frame: .init(x: 0, y: 0, width: 100, height: 100))
48 | PlaygroundPage.current.liveView = WrapperView(wrappedView: view)
49 | PlaygroundPage.current.needsIndefiniteExecution = true
50 |
51 | propertyAssignmentAnimation.perform(on: view)
52 |
53 | //: [Next](@next)
54 |
--------------------------------------------------------------------------------
/Sources/Stagehand/AnimationCurve/AnimationCurve.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | /// Defines the interface for a function that controls the apparent progress of an animation. More specifically, it
20 | /// converts a "raw" progress amount (in the range `[0,1]`, where 0 is the start of the animation and 1 is the end of
21 | /// the animation) to a curved progress amount.
22 | ///
23 | /// The domain of the function is strictly bounded to `[0,1]`. The range of the function is technically unbounded, but
24 | /// in most cases will also be `[0,1]`. In any case, the function should be defined such that `f(0) = 0` and `f(1) = 1`;
25 | /// otherwise the animation will not end on the specified final keyframes.
26 | public protocol AnimationCurve {
27 |
28 | func adjustedProgress(for progress: Double) -> Double
29 |
30 | /// The raw (uncurved) progress values that correspond to the specified adjusted (curved) progress.
31 | ///
32 | /// Note that unlike the raw -> adjusted calculation that always results in one value, there may be multiple raw
33 | /// values corresponding to one adjusted value. In other words, X(t) is monotonic, but Y(t) is not.
34 | func rawProgress(for adjustedProgress: Double) -> [Double]
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Animation Groups.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | import Stagehand
4 |
5 | /*:
6 |
7 | # Animation Groups
8 |
9 | Animation groups allow multiple elements to be animated together, even if they can't be accessed via key paths from a
10 | single parent element. Constructing an animation group is similar to composing an animation hierarchy, except using
11 | references to instances of elements rather than key paths.
12 |
13 | */
14 |
15 | var firstAnimation = Animation()
16 | var firstElement = UIView()
17 |
18 | var secondAnimation = Animation()
19 | var secondElement = CALayer()
20 |
21 | var animationGroup = AnimationGroup()
22 | animationGroup.addAnimation(firstAnimation, for: firstElement, startingAt: 0, relativeDuration: 1)
23 | animationGroup.addAnimation(secondAnimation, for: secondElement, startingAt: 0, relativeDuration: 1)
24 |
25 | /*:
26 |
27 | Like normal `Animation`s, we can change the `implicitDuration`, `curve`, and `implicitRepeatStyle` of our animation
28 | group as a whole.
29 |
30 | When we're ready to perform the animation, we call the `perform(delay:completion:)` method.
31 |
32 | */
33 |
34 | animationGroup.perform()
35 |
36 | /*:
37 |
38 | Since animation groups need to know about the instance during construction, they break the concept of separating the
39 | construction and execution of animations.
40 |
41 | Animation groups also hold a strong reference to each of the elements they animate. It is up to consumers to cancel
42 | the returned animation instance if the elements need to be deallocated before the animation completes naturally, as
43 | this will not happen automatically.
44 |
45 | For these reasons, it is generally preferrable to use an `Animation` when each of the subelements can be accessed via
46 | key paths.
47 |
48 | */
49 |
50 | //: [Next](@next)
51 |
--------------------------------------------------------------------------------
/Example/Stagehand.xcodeproj/xcshareddata/xcbaselines/3D32EF3B23404F6A001144B3.xcbaseline/6B34E5D7-3D0D-449C-BAAC-3F2D290EF8A9.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | classNames
6 |
7 | TransformPerformanceTests
8 |
9 | testCATransform3DInterpolationPerformance_complexTransforms()
10 |
11 | com.apple.XCTPerformanceMetric_WallClockTime
12 |
13 | baselineAverage
14 | 11.368
15 | baselineIntegrationDisplayName
16 | Aug 14, 2020 at 5:54:49 PM
17 |
18 |
19 | testCATransform3DInterpolationPerformance_identityToIdentity()
20 |
21 | com.apple.XCTPerformanceMetric_WallClockTime
22 |
23 | baselineAverage
24 | 1.14
25 | baselineIntegrationDisplayName
26 | Local Baseline
27 |
28 |
29 | testCGAffineTransformInterpolationPerformance_complexTransforms()
30 |
31 | com.apple.XCTPerformanceMetric_WallClockTime
32 |
33 | baselineAverage
34 | 0.911
35 | baselineIntegrationDisplayName
36 | Local Baseline
37 |
38 |
39 | testCGAffineTransformInterpolationPerformance_identityToIdentity()
40 |
41 | com.apple.XCTPerformanceMetric_WallClockTime
42 |
43 | baselineAverage
44 | 0.773
45 | baselineIntegrationDisplayName
46 | Local Baseline
47 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/Scripts/rename-snapshots.swift:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env swift
2 |
3 | import Foundation
4 |
5 | enum TaskError: Error {
6 | case code(Int32)
7 | }
8 |
9 | guard CommandLine.arguments.count > 2 else {
10 | print("Usage: rename-snapshots.swift ")
11 | throw TaskError.code(1)
12 | }
13 |
14 | let rawFromVersion = CommandLine.arguments[1]
15 | let rawToVersion = CommandLine.arguments[2]
16 |
17 | let fromVersionA = rawFromVersion.replacingOccurrences(of: ".", with: "_")
18 | let fromVersionB = rawFromVersion.replacingOccurrences(of: ".", with: "-")
19 | let toVersionA = rawToVersion.replacingOccurrences(of: ".", with: "_")
20 | let toVersionB = rawToVersion.replacingOccurrences(of: ".", with: "-")
21 |
22 | let fileManager = FileManager.default
23 | let currentDirectoryPath: NSString = fileManager.currentDirectoryPath as NSString
24 |
25 | let referenceImageDirectoryPaths = [
26 | currentDirectoryPath.appendingPathComponent("Example/Unit Tests/__Snapshots__"),
27 | currentDirectoryPath.appendingPathComponent("Example/Unit Tests/ReferenceImages"),
28 | ]
29 |
30 | for referenceImageDirectoryPath in referenceImageDirectoryPaths {
31 | guard let enumerator = fileManager.enumerator(atPath: referenceImageDirectoryPath) else {
32 | print("Invalid reference image directory path: \(referenceImageDirectoryPath)")
33 | throw TaskError.code(1)
34 | }
35 |
36 | while let filePath = enumerator.nextObject() as? String {
37 | guard filePath.hasSuffix(".png") else { continue }
38 |
39 | let fullPath = (referenceImageDirectoryPath as NSString).appendingPathComponent(filePath) as String
40 |
41 | let newPath = fullPath
42 | .replacingOccurrences(of: fromVersionA, with: toVersionA)
43 | .replacingOccurrences(of: fromVersionB, with: toVersionB)
44 |
45 | try fileManager.moveItem(
46 | atPath: fullPath,
47 | toPath: newPath
48 | )
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Example/Unit Tests/CATransform3D+Additions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import UIKit
18 |
19 | extension CATransform3D {
20 |
21 | func translatedBy(x: CGFloat = 0, y: CGFloat = 0, z: CGFloat = 0) -> CATransform3D {
22 | return CATransform3DTranslate(self, x, y, z)
23 | }
24 |
25 | func scaledBy(x: CGFloat = 1, y: CGFloat = 1, z: CGFloat = 1) -> CATransform3D {
26 | return CATransform3DScale(self, x, y, z)
27 | }
28 |
29 | func rotatedBy(angle: CGFloat, x: CGFloat, y: CGFloat, z: CGFloat) -> CATransform3D {
30 | return CATransform3DRotate(self, angle, x, y, z)
31 | }
32 |
33 | func shearedBy(
34 | xy: CGFloat = 0,
35 | yx: CGFloat = 0,
36 | xz: CGFloat = 0,
37 | zx: CGFloat = 0,
38 | yz: CGFloat = 0,
39 | zy: CGFloat = 0
40 | ) -> CATransform3D {
41 | var shearMatrix = CATransform3DIdentity
42 | shearMatrix.m12 = yx
43 | shearMatrix.m13 = zx
44 | shearMatrix.m21 = xy
45 | shearMatrix.m23 = zy
46 | shearMatrix.m31 = xz
47 | shearMatrix.m32 = yz
48 | return CATransform3DConcat(shearMatrix, self)
49 | }
50 |
51 | func withPerspective(eyePosition: CGFloat) -> CATransform3D {
52 | var transform = self
53 | transform.m34 = -1 / eyePosition
54 | return transform
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Snapshot Testing Animations.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | import Stagehand
4 | import StagehandTesting
5 | import UIKit
6 |
7 | /*:
8 |
9 | # Snapshot Testing Animations
10 |
11 | Once we've written our animation, it's important to ensure it doesn't regress as other changes are made. To accomplish
12 | this, Stagehand ships with a second framework, StagehandTesting, to enable writing snapshot tests for animations.
13 |
14 | Animation snapshot tests are built on top of the iOSSnapshotTestCase framework, so all of the same setup and behaviors
15 | will apply. To write an animation snapshot test, simply set up your animation and view, and call one of the snapshot
16 | verification methods.
17 |
18 | */
19 |
20 | /*:
21 |
22 | To snapshot an animation at a single frame, use `SnapshotVerify(animation:on:at:)`:
23 |
24 | */
25 |
26 | var animation = Animation()
27 |
28 | var view = UIView()
29 |
30 | // Snapshot the animation at the midpoint.
31 | SnapshotVerify(
32 | animation: animation,
33 | view: view,
34 | at: 0.5
35 | )
36 |
37 | /*:
38 |
39 | A similar method exists for snapshotting animation groups. In this case, you must also provide a view with which the
40 | animation can be verified, since the group doesn't know what the parent view of its elements is.
41 |
42 | */
43 |
44 | var animationGroup = AnimationGroup()
45 |
46 | SnapshotVerify(
47 | animationGroup: animationGroup,
48 | using: view,
49 | at: 0.5
50 | )
51 |
52 | /*:
53 |
54 | Beyond static snapshots of a specific frame, StagehandTesting can output animated GIFs of the entire animation.
55 |
56 | */
57 |
58 | SnapshotVerify(
59 | animation: animation,
60 | on: view
61 | )
62 |
63 | /*:
64 |
65 | This methods has a few other parameters, such as `fps` and `bookendFrameDuration`, that can be used to tailor the
66 | output to your needs. Check out the header docs for that method for more details.
67 |
68 | */
69 |
70 | //: [Next](@next)
71 |
--------------------------------------------------------------------------------
/Sources/Stagehand/Driver/Driver.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | /// Defines the interface for an animation driver, the mechanism by which the progress an `AnimationInstance` is
20 | /// controlled. This can be either a self-contained driver, like the `DisplayLinkDriver`, which controls the animation
21 | /// on its own; or a conversion driver, like the `SnapshotTestDriver`, that controls the animation based on the inputs
22 | /// to the driver.
23 | ///
24 | /// This will also allow for drivers to control interactive animations in the future, where the driver converts its
25 | /// input (for example, a gesture recognizer) into the corresponding progress of an animation.
26 | protocol Driver: AnyObject {
27 |
28 | var animationInstance: DrivenAnimationInstance! { get set }
29 |
30 | func animationInstanceDidInitialize()
31 |
32 | func animationInstanceDidCancel(behavior: AnimationInstance.CancelationBehavior)
33 |
34 | }
35 |
36 | // MARK: -
37 |
38 | protocol DrivenAnimationInstance: AnyObject {
39 |
40 | func executeBlocks(
41 | from startingRelativeTimestamp: Double,
42 | _ fromInclusivity: Executor.Inclusivity,
43 | to endingRelativeTimestamp: Double
44 | )
45 |
46 | func renderFrame(at relativeTimestamp: Double)
47 |
48 | func markAnimationAsComplete()
49 |
50 | }
51 |
52 | extension AnimationInstance: DrivenAnimationInstance { }
53 |
--------------------------------------------------------------------------------
/Sources/Stagehand/Utilities/Vector3.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import CoreGraphics
18 |
19 | internal struct Vector3 {
20 |
21 | // MARK: - Public Properties
22 |
23 | var v1: CGFloat
24 |
25 | var v2: CGFloat
26 |
27 | var v3: CGFloat
28 |
29 | // MARK: - Public Methods
30 |
31 | func length() -> CGFloat {
32 | return sqrt(v1 * v1 + v2 * v2 + v3 * v3)
33 | }
34 |
35 | func cross(_ other: Vector3) -> Vector3 {
36 | return .init(
37 | v1: (self.v2 * other.v3) - (self.v3 * other.v2),
38 | v2: (self.v3 * other.v1) - (self.v1 * other.v3),
39 | v3: (self.v1 * other.v2) - (self.v2 * other.v1)
40 | )
41 | }
42 |
43 | func dot(_ other: Vector3) -> CGFloat {
44 | return (self.v1 * other.v1) + (self.v2 * other.v2) + (self.v3 * other.v3)
45 | }
46 |
47 | mutating func normalize() {
48 | let length = self.length()
49 | guard length != 0 else {
50 | return
51 | }
52 |
53 | let multiplier = (1 / length)
54 | v1 *= multiplier
55 | v2 *= multiplier
56 | v3 *= multiplier
57 | }
58 |
59 | // MARK: - Operators
60 |
61 | static func * (left: CGFloat, right: Vector3) -> Vector3 {
62 | return Vector3(v1: left * right.v1, v2: left * right.v2, v3: left * right.v3)
63 | }
64 |
65 | static func + (left: Vector3, right: Vector3) -> Vector3 {
66 | return Vector3(v1: left.v1 + right.v1, v2: left.v2 + right.v2, v3: left.v3 + right.v3)
67 | }
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Repeating Animations.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | import Stagehand
4 |
5 | /*:
6 |
7 | # Repeating Animations
8 |
9 | By default, animations will run once before completing. Sometimes, though, we want our animation to loop through
10 | multiple times, sometimes even indefinitely.
11 |
12 | Using the `Animation.implicitRepeatStyle` property, we can control how our animation repeats.
13 |
14 | */
15 |
16 | var animation = Animation()
17 |
18 | // The default style is to not repeat.
19 | animation.implicitRepeatStyle = .noRepeat
20 |
21 | /*:
22 |
23 | To have our animation repeat one more time after it completes, we can set the repeat style to `.repeating` with a count
24 | of 2 (for two total cycles of the animation).
25 |
26 | */
27 |
28 | animation.implicitRepeatStyle = .repeating(count: 2, autoreversing: false)
29 |
30 | /*:
31 |
32 | We can also set our animation to repeat indefinitely. With this set, our animation will only stop when it is cancelled
33 | (either by calling `cancel(behavior:)` on the instance, or if the element it is animating is deallocated).
34 |
35 | */
36 |
37 | animation.implicitRepeatStyle = .infinitelyRepeating(autoreversing: false)
38 |
39 | /*:
40 |
41 | The `autoreversing` parameter determines whether alternating cycles are run in opposite directions. Typically,
42 | animations that start and end in the same state (e.g. a spinner) will use `false`. Other animations may not reverse,
43 | but many will.
44 |
45 | */
46 |
47 | /*:
48 |
49 | ## Handling Reverse Animations
50 |
51 | When we do use reversing animations, we need to make sure our animation is set up to handle a reverse cycle.
52 |
53 | Keyframes, property assignments, and per-frame execution blocks have support for reverse cycles built in. With standard
54 | execution blocks, the default behavior on a reverse cycle is to no-op. If this is not the expected behavior, we need to
55 | provide a closure to run on the reverse cycles.
56 |
57 | Read on to the [Executing Code During Animations](Executing%20Code%20During%20Animations) for more details.
58 |
59 | */
60 |
61 | //: [Next](@next)
62 |
--------------------------------------------------------------------------------
/Example/Unit Tests/AnimatableContainerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import UIKit
18 |
19 | final class AnimatableContainerView: UIView {
20 |
21 | // MARK: - Life Cycle
22 |
23 | override init(frame: CGRect) {
24 | super.init(frame: frame)
25 |
26 | animatableView.backgroundColor = .red
27 | addSubview(animatableView)
28 | }
29 |
30 | @available(*, unavailable)
31 | required init?(coder: NSCoder) {
32 | fatalError("init(coder:) has not been implemented")
33 | }
34 |
35 | // MARK: - Public Properties
36 |
37 | let animatableView: UIView = .init()
38 |
39 | // MARK: - UIView
40 |
41 | override func layoutSubviews() {
42 | animatableView.bounds.size = .init(width: 20, height: 20)
43 | animatableView.center = .init(x: 20, y: bounds.midY)
44 | }
45 |
46 | }
47 |
48 | // MARK: -
49 |
50 | extension AnimatableContainerView {
51 |
52 | final class Proxy {
53 |
54 | // MARK: - Life Cycle
55 |
56 | init(view: AnimatableContainerView) {
57 | self.view = view
58 | }
59 |
60 | // MARK: - Public Properties
61 |
62 | public var animatableViewTransform: CGAffineTransform {
63 | get {
64 | return view.animatableView.transform
65 | }
66 | set {
67 | view.animatableView.transform = newValue
68 | }
69 | }
70 |
71 | // MARK: - Private Properties
72 |
73 | private let view: AnimatableContainerView
74 |
75 | }
76 |
77 | }
78 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 |
9 | jobs:
10 | pod-lint:
11 | name: Pod Lint
12 | runs-on: macos-latest
13 | steps:
14 | - name: Checkout Repo
15 | uses: actions/checkout@v4
16 | - name: Bundle Install
17 | run: bundle install --gemfile=Example/Gemfile
18 | - name: Lint podspecs
19 | run: bundle exec --gemfile=Example/Gemfile pod lib lint --verbose --fail-fast --include-podspecs=Stagehand.podspec StagehandTesting.podspec
20 | spm:
21 | name: SPM Build
22 | runs-on: macos-latest
23 | strategy:
24 | fail-fast: false
25 | steps:
26 | - name: Checkout Repo
27 | uses: actions/checkout@v4
28 | - name: Build
29 | run: |
30 | # select minimum supported Xcode version
31 | sudo xcode-select -s /Applications/Xcode_14.3.1.app
32 | # TODO: add SPM test target, for now just run build on all products.
33 | xcodebuild build \
34 | -scheme Stagehand-Package \
35 | -sdk iphonesimulator16.4 \
36 | -destination "OS=16.4,name=iPhone 13 Pro"
37 |
38 | xcode-build:
39 | name: Xcode Build
40 | runs-on: macOS-14
41 | strategy:
42 | fail-fast: false
43 | steps:
44 | - name: Checkout Repo
45 | uses: actions/checkout@v4
46 | - name: Bundle Install
47 | run: bundle install --gemfile=Example/Gemfile
48 | - name: Pod Install
49 | run: bundle exec --gemfile=Example/Gemfile pod install --project-directory=Example
50 | - name: Select Xcode Version
51 | run: sudo xcode-select -s /Applications/Xcode_16.app
52 | - name: Build and Test
53 | run: |
54 | # run tests on the example app
55 | xcodebuild test \
56 | -workspace Example/Stagehand.xcworkspace \
57 | -scheme "Stagehand Demo App" \
58 | -sdk iphonesimulator \
59 | -destination "platform=iOS Simulator,OS=18.0,name=iPhone 16 Pro"
60 | - name: Upload Results
61 | uses: actions/upload-artifact@v4
62 | if: failure()
63 | with:
64 | name: Test Results
65 | path: .build/derivedData/**/Logs/Test/*.xcresult
66 |
--------------------------------------------------------------------------------
/Sources/Stagehand/AnimatableProperty/AnimatableProperty+CoreGraphics.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import CoreGraphics
18 |
19 | extension CGFloat: AnimatableProperty {
20 |
21 | public static func value(between initialValue: CGFloat, and finalValue: CGFloat, at progress: Double) -> CGFloat {
22 | return initialValue + CGFloat(progress) * (finalValue - initialValue)
23 | }
24 |
25 | }
26 |
27 | extension CGPoint: AnimatableProperty {
28 |
29 | public static func value(between initialValue: CGPoint, and finalValue: CGPoint, at progress: Double) -> CGPoint {
30 | return CGPoint(
31 | x: CGFloat.value(between: initialValue.x, and: finalValue.x, at: progress),
32 | y: CGFloat.value(between: initialValue.y, and: finalValue.y, at: progress)
33 | )
34 | }
35 |
36 | }
37 |
38 | extension CGSize: AnimatableProperty {
39 |
40 | public static func value(between initialValue: CGSize, and finalValue: CGSize, at progress: Double) -> CGSize {
41 | return CGSize(
42 | width: CGFloat.value(between: initialValue.width, and: finalValue.width, at: progress),
43 | height: CGFloat.value(between: initialValue.height, and: finalValue.height, at: progress)
44 | )
45 | }
46 |
47 | }
48 |
49 | extension CGRect: AnimatableProperty {
50 |
51 | public static func value(between initialValue: CGRect, and finalValue: CGRect, at progress: Double) -> CGRect {
52 | return CGRect(
53 | origin: CGPoint.value(between: initialValue.origin, and: finalValue.origin, at: progress),
54 | size: CGSize.value(between: initialValue.size, and: finalValue.size, at: progress)
55 | )
56 | }
57 |
58 | }
59 |
--------------------------------------------------------------------------------
/Example/Stagehand.xcodeproj/xcshareddata/xcbaselines/3D32EF3B23404F6A001144B3.xcbaseline/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | runDestinationsByUUID
6 |
7 | 4347C282-2559-4EFF-A169-EC7DCEFA739B
8 |
9 | localComputer
10 |
11 | busSpeedInMHz
12 | 100
13 | cpuCount
14 | 1
15 | cpuKind
16 | Intel Core i7
17 | cpuSpeedInMHz
18 | 2800
19 | logicalCPUCoresPerPackage
20 | 8
21 | modelCode
22 | MacBookPro14,3
23 | physicalCPUCoresPerPackage
24 | 4
25 | platformIdentifier
26 | com.apple.platform.macosx
27 |
28 | targetArchitecture
29 | x86_64
30 | targetDevice
31 |
32 | modelCode
33 | iPhone11,2
34 | platformIdentifier
35 | com.apple.platform.iphonesimulator
36 |
37 |
38 | 6B34E5D7-3D0D-449C-BAAC-3F2D290EF8A9
39 |
40 | localComputer
41 |
42 | busSpeedInMHz
43 | 400
44 | cpuCount
45 | 1
46 | cpuKind
47 | 8-Core Intel Core i9
48 | cpuSpeedInMHz
49 | 2400
50 | logicalCPUCoresPerPackage
51 | 16
52 | modelCode
53 | MacBookPro15,1
54 | physicalCPUCoresPerPackage
55 | 8
56 | platformIdentifier
57 | com.apple.platform.macosx
58 |
59 | targetArchitecture
60 | x86_64
61 | targetDevice
62 |
63 | modelCode
64 | iPhone11,2
65 | platformIdentifier
66 | com.apple.platform.iphonesimulator
67 |
68 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/Example/Performance Tests/AnimationCurvePerformanceTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 | import XCTest
19 |
20 | final class AnimationCurvePerformanceTests: XCTestCase {
21 |
22 | func testParabolicEaseInPerformance() {
23 | let curve = ParabolicEaseInAnimationCurve()
24 | measure {
25 | for _ in 0...100 {
26 | for i in 0...10000 {
27 | let progress = Double(i) / 10000
28 | _ = curve.adjustedProgress(for: progress)
29 | }
30 | }
31 | }
32 | }
33 |
34 | func testParabolicEaseOutPerformance() {
35 | let curve = ParabolicEaseOutAnimationCurve()
36 | measure {
37 | for _ in 0...100 {
38 | for i in 0...10000 {
39 | let progress = Double(i) / 10000
40 | _ = curve.adjustedProgress(for: progress)
41 | }
42 | }
43 | }
44 | }
45 |
46 | func testSinusoidalEaseInEaseOutPerformance() {
47 | let curve = SinusoidalEaseInEaseOutAnimationCurve()
48 | measure {
49 | for _ in 0...100 {
50 | for i in 0...10000 {
51 | let progress = Double(i) / 10000
52 | _ = curve.adjustedProgress(for: progress)
53 | }
54 | }
55 | }
56 | }
57 |
58 | func testCubicBezierEaseInPerformance() {
59 | let curve = CubicBezierAnimationCurve.easeIn
60 | measure {
61 | for i in 0...10000 {
62 | let progress = Double(i) / 10000
63 | _ = curve.adjustedProgress(for: progress)
64 | }
65 | }
66 | }
67 |
68 | }
69 |
--------------------------------------------------------------------------------
/Example/Unit Tests/SnapshotTestCase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import FBSnapshotTestCase
18 |
19 | class SnapshotTestCase: FBSnapshotTestCase {
20 |
21 | // MARK: - Private Types
22 |
23 | private struct TestDeviceConfig {
24 |
25 | // MARK: - Public Properties
26 |
27 | let systemVersion: String
28 | let screenSize: CGSize
29 | let screenScale: CGFloat
30 |
31 | // MARK: - Public Methods
32 |
33 | func matchesCurrentDevice() -> Bool {
34 | let device = UIDevice.current
35 | let screen = UIScreen.main
36 |
37 | return device.systemVersion == systemVersion
38 | && screen.bounds.size == screenSize
39 | && screen.scale == screenScale
40 | }
41 |
42 | }
43 |
44 | // MARK: - Private Static Properties
45 |
46 | private static let testedDevices = [
47 | // iPhone 16 Pro - iOS 18.0
48 | TestDeviceConfig(systemVersion: "18.0", screenSize: CGSize(width: 402, height: 874), screenScale: 3),
49 | ]
50 |
51 | // MARK: - FBSnapshotTestCase
52 |
53 | override func setUp() {
54 | super.setUp()
55 |
56 | guard SnapshotTestCase.testedDevices.contains(where: { $0.matchesCurrentDevice() }) else {
57 | fatalError("Attempting to run tests on a device for which we have not collected test data")
58 | }
59 |
60 | guard ProcessInfo.processInfo.environment["FB_REFERENCE_IMAGE_DIR"] != nil else {
61 | fatalError("The environment variable FB_REFERENCE_IMAGE_DIR must be set for the current scheme")
62 | }
63 |
64 | fileNameOptions = [.OS, .screenSize, .screenScale]
65 |
66 | recordMode = false
67 | }
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/Stagehand/AnimatableProperty/AnimatableProperty+Optional.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | // Unfortunately, Swift doesn't support multiple conditional conformances for the same type, so we can't declare
20 | // separate conformances of `Optional` to `AnimatableProperty` for each optional type that can be animated. To work
21 | // around this, we can define the `AnimatableOptionalProperty` protocol and have `Optional` conform to
22 | // `AnimatableProperty` whenever its `Wrapped` type conforms to this protocol.
23 |
24 | /// Defines the interface of a type for which optional values of the type can be animated. More specifically,
25 | /// interpolates between two optional values (the `initialValue` and `finalValue`) at a given `progress`.
26 | public protocol AnimatableOptionalProperty {
27 |
28 | /// Returns an interpolation between the `initialValue` and `finalValue` at the given `progress` in the range.
29 | ///
30 | /// - parameter initialValue: The initial value of the interpolation, i.e. the value when the `progress` is 0.
31 | /// - parameter finalValue: The final value of the interpolation, i.e. the value when the `progress` is 1.
32 | /// - parameter progress: The progress along the interpolation, in the range `[0,1]`.
33 | static func optionalValue(between initialValue: Self?, and finalValue: Self?, at progress: Double) -> Self?
34 |
35 | }
36 |
37 | extension Optional: AnimatableProperty where Wrapped: AnimatableOptionalProperty {
38 |
39 | public static func value(between initialValue: Optional, and finalValue: Optional, at progress: Double) -> Optional {
40 | return Wrapped.optionalValue(between: initialValue, and: finalValue, at: progress)
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/Example/Unit Tests/QuadrantView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import UIKit
18 |
19 | final class QuadrantView: UIView {
20 |
21 | // MARK: - Life Cycle
22 |
23 | override init(frame: CGRect) {
24 | super.init(frame: frame)
25 |
26 | topLeftLayer.backgroundColor = UIColor.red.cgColor
27 | layer.addSublayer(topLeftLayer)
28 |
29 | topRightLayer.backgroundColor = UIColor.green.cgColor
30 | layer.addSublayer(topRightLayer)
31 |
32 | bottomLeftLayer.backgroundColor = UIColor.blue.cgColor
33 | layer.addSublayer(bottomLeftLayer)
34 |
35 | bottomRightLayer.backgroundColor = UIColor.yellow.cgColor
36 | layer.addSublayer(bottomRightLayer)
37 | }
38 |
39 | @available(*, unavailable)
40 | required init?(coder: NSCoder) {
41 | fatalError("init(coder:) has not been implemented")
42 | }
43 |
44 | // MARK: - Private Methods
45 |
46 | private let topLeftLayer: CALayer = .init()
47 | private let topRightLayer: CALayer = .init()
48 | private let bottomLeftLayer: CALayer = .init()
49 | private let bottomRightLayer: CALayer = .init()
50 |
51 | // MARK: - UIView
52 |
53 | override func layoutSubviews() {
54 | [topLeftLayer, topRightLayer, bottomLeftLayer, bottomRightLayer].forEach {
55 | $0.bounds.size = .init(width: bounds.width / 2, height: bounds.height / 2)
56 | }
57 |
58 | topLeftLayer.position = .init(x: bounds.width * 0.25, y: bounds.height * 0.25)
59 | topRightLayer.position = .init(x: bounds.width * 0.75, y: bounds.height * 0.25)
60 | bottomLeftLayer.position = .init(x: bounds.width * 0.25, y: bounds.height * 0.75)
61 | bottomRightLayer.position = .init(x: bounds.width * 0.75, y: bounds.height * 0.75)
62 | }
63 |
64 | }
65 |
--------------------------------------------------------------------------------
/Example/Stagehand/SimpleAnimationsViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import UIKit
18 |
19 | final class SimpleAnimationsViewController: DemoViewController {
20 |
21 | // MARK: - Life Cycle
22 |
23 | override init() {
24 | super.init()
25 |
26 | contentView = mainView
27 |
28 | animationRows = [
29 | ("Fade Out", { [unowned self] in
30 | let animation = AnimationFactory.makeFadeOutAnimation()
31 | animation.perform(on: self.mainView.animatableView)
32 | }),
33 | ("Fade In", { [unowned self] in
34 | let animation = AnimationFactory.makeFadeInAnimation()
35 | animation.perform(on: self.mainView.animatableView)
36 | }),
37 | ]
38 | }
39 |
40 | // MARK: - Private Properties
41 |
42 | private let mainView: View = .init()
43 |
44 | }
45 |
46 | // MARK: -
47 |
48 | extension SimpleAnimationsViewController {
49 |
50 | final class View: UIView {
51 |
52 | // MARK: - Life Cycle
53 |
54 | override init(frame: CGRect) {
55 | super.init(frame: frame)
56 |
57 | animatableView.frame.size = .init(width: 50, height: 50)
58 | animatableView.backgroundColor = .red
59 | addSubview(animatableView)
60 | }
61 |
62 | @available(*, unavailable)
63 | required init?(coder: NSCoder) {
64 | fatalError("init(coder:) has not been implemented")
65 | }
66 |
67 | // MARK: - Public Properties
68 |
69 | let animatableView: UIView = .init()
70 |
71 | // MARK: - UIView
72 |
73 | override func layoutSubviews() {
74 | animatableView.center = .init(
75 | x: (bounds.maxX - bounds.minX) / 2,
76 | y: (bounds.maxY - bounds.minY) / 2
77 | )
78 | }
79 |
80 | }
81 |
82 | }
83 |
--------------------------------------------------------------------------------
/Example/Stagehand/Base.lproj/LaunchScreen.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/Sources/Stagehand/AnimationInstance/Renderer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 | import QuartzCore
19 |
20 | internal final class Renderer: AnyRenderer {
21 |
22 | // MARK: - Life Cycle
23 |
24 | internal init(
25 | animation: Animation,
26 | element: ElementType
27 | ) {
28 | self.animation = animation
29 | self.element = element
30 | }
31 |
32 | // MARK: - Private Properties
33 |
34 | private let animation: Animation
35 |
36 | private weak var element: ElementType?
37 |
38 | private lazy var initialValues: Dictionary, Any> = {
39 | guard let element = element else {
40 | return [:]
41 | }
42 |
43 | return Dictionary(
44 | uniqueKeysWithValues: animation.propertiesWithKeyframes.map { ($0, element[keyPath: $0]) }
45 | )
46 | }()
47 |
48 | // MARK: - Internal Methods
49 |
50 | func canRenderFrame() -> Bool {
51 | return (element != nil)
52 | }
53 |
54 | func renderFrame(at relativeTimestamp: Double) {
55 | guard var element = self.element else {
56 | return
57 | }
58 |
59 | // Disable implicit layer animations while rendering the frame. Otherwise animations that include keyframes for animatable layer properties will not animate those properties in sync with the rest of the animation.
60 | CATransaction.begin()
61 | CATransaction.setDisableActions(true)
62 |
63 | animation.apply(to: &element, at: relativeTimestamp, initialValues: initialValues)
64 |
65 | CATransaction.commit()
66 | }
67 |
68 | func renderInitialFrame() {
69 | guard var element = self.element else {
70 | return
71 | }
72 |
73 | animation.applyInitialKeyframes(to: &element, initialValues: initialValues)
74 | }
75 |
76 | }
77 |
78 | // MARK: -
79 |
80 | internal protocol AnyRenderer {
81 |
82 | func canRenderFrame() -> Bool
83 |
84 | func renderFrame(at relativeTimestamp: Double)
85 |
86 | func renderInitialFrame()
87 |
88 | }
89 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Executing Code Every Frame.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | /*:
4 |
5 | # Executing Code Every Frame
6 |
7 | Keyframes make it easy to interpolate properties over the course of our animation, execution blocks let us add actions
8 | at specific points in the animation, and property assignments make changing properties to discrete values easy. But
9 | sometimes we need something more manual, requiring us to run code every frame.
10 |
11 | */
12 |
13 | /*:
14 |
15 | ## The Traditional Approach
16 |
17 | Traditionally, executing code during every render cycle can be done via a display link.
18 |
19 | */
20 |
21 | import QuartzCore
22 |
23 | final class DisplayLinkAnimator {
24 |
25 | // MARK: - Life Cycle
26 |
27 | init() {
28 | self.startTime = CFAbsoluteTime()
29 | self.displayLink = CADisplayLink(target: self, selector: #selector(renderFrame))
30 | }
31 |
32 | // MARK: - Private Properties
33 |
34 | private var displayLink: CADisplayLink!
35 |
36 | private let startTime: CFTimeInterval
37 |
38 | // MARK: - Public Methods
39 |
40 | func start() {
41 | displayLink.add(to: .current, forMode: .common)
42 | }
43 |
44 | @objc func renderFrame() {
45 | // Do some stuff here.
46 | }
47 |
48 | }
49 |
50 | /*:
51 |
52 | This comes with some standard boilerplate, but is managable. The bigger problem comes when we try to mix this with
53 | other animation code. Usually we end up having to get rid of other animations and render each frame manually, in order
54 | to make sure everything stays in sync.
55 |
56 |
57 | ## Adding Per-Frame Execution Blocks
58 |
59 | Stagehand makes mixing traditional animation techniques (like keyframes and execution blocks) and per-frame render code
60 | easy. The `Animation` struct has an `addPerFrameExecution(_:)` method to add a new block to be executed each frame.
61 |
62 | This block is called with a context that contains a variety of data, like the element being animated and the current
63 | progress into the animation.
64 |
65 | */
66 |
67 | import Stagehand
68 |
69 | var animation = Animation()
70 |
71 | animation.addPerFrameExecution { context in
72 | // The `context` contains the important things we need during our animation. Our view (the element we're animating)
73 | // is available via the `context.element`:
74 | _ = context.element
75 | }
76 |
77 | /*:
78 |
79 | Per-frame execution blocks will be executed _after_ all other parts of the animation have been applied (interpolating
80 | between keyframes, running any execution blocks, etc.). This means you can have logic in your per-frame execution block
81 | that calculates values using properties that are interpolated.
82 |
83 | */
84 |
85 | //: [Next](@next)
86 |
--------------------------------------------------------------------------------
/Sources/Stagehand/Utilities/Quaternions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import CoreGraphics
18 |
19 | struct Quaternion: Equatable {
20 |
21 | // MARK: - Internal Properties
22 |
23 | var x: CGFloat = 0
24 |
25 | var y: CGFloat = 0
26 |
27 | var z: CGFloat = 0
28 |
29 | var w: CGFloat = 1
30 |
31 | // MARK: - Internal Static Methods
32 |
33 | /// Interpolate between two quaternions using spherical linear interpolation.
34 | static func value(
35 | between initialValue: Quaternion,
36 | and finalValue: Quaternion,
37 | at progress: Double
38 | ) -> Quaternion {
39 | var initialValue = initialValue
40 | var finalValue = finalValue
41 | let progress = CGFloat(progress)
42 |
43 | var angle = initialValue.x * finalValue.x
44 | + initialValue.y * finalValue.y
45 | + initialValue.z * finalValue.z
46 | + initialValue.w * finalValue.w
47 |
48 | if angle < 0 {
49 | initialValue.x *= -1
50 | initialValue.y *= -1
51 | initialValue.z *= -1
52 | initialValue.w *= -1
53 | angle *= -1
54 | }
55 |
56 | let scale: CGFloat
57 | let invscale: CGFloat
58 | if angle + 1 > 0.05 && 1 - angle >= 0.05 {
59 | let th = acos(angle)
60 | let invth = 1 / sin(th)
61 | scale = sin(th * (1 - progress)) * invth
62 | invscale = sin(th * progress) * invth
63 |
64 | } else if angle + 1 > 0.05 {
65 | scale = 1 - progress
66 | invscale = progress
67 |
68 | } else {
69 | finalValue.x = -initialValue.y
70 | finalValue.y = initialValue.x
71 | finalValue.z = -initialValue.w
72 | finalValue.w = initialValue.z
73 |
74 | scale = sin(.pi * (0.5 - progress))
75 | invscale = sin(.pi * progress)
76 | }
77 |
78 | return Quaternion(
79 | x: initialValue.x * scale + finalValue.x * invscale,
80 | y: initialValue.y * scale + finalValue.y * invscale,
81 | z: initialValue.z * scale + finalValue.z * invscale,
82 | w: initialValue.w * scale + finalValue.w * invscale
83 | )
84 | }
85 |
86 | }
87 |
--------------------------------------------------------------------------------
/Example/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | CFPropertyList (3.0.6)
5 | rexml
6 | activesupport (7.1.3)
7 | base64
8 | bigdecimal
9 | concurrent-ruby (~> 1.0, >= 1.0.2)
10 | connection_pool (>= 2.2.5)
11 | drb
12 | i18n (>= 1.6, < 2)
13 | minitest (>= 5.1)
14 | mutex_m
15 | tzinfo (~> 2.0)
16 | addressable (2.8.6)
17 | public_suffix (>= 2.0.2, < 6.0)
18 | algoliasearch (1.27.5)
19 | httpclient (~> 2.8, >= 2.8.3)
20 | json (>= 1.5.1)
21 | atomos (0.1.3)
22 | base64 (0.2.0)
23 | bigdecimal (3.1.6)
24 | claide (1.1.0)
25 | cocoapods (1.14.3)
26 | addressable (~> 2.8)
27 | claide (>= 1.0.2, < 2.0)
28 | cocoapods-core (= 1.14.3)
29 | cocoapods-deintegrate (>= 1.0.3, < 2.0)
30 | cocoapods-downloader (>= 2.1, < 3.0)
31 | cocoapods-plugins (>= 1.0.0, < 2.0)
32 | cocoapods-search (>= 1.0.0, < 2.0)
33 | cocoapods-trunk (>= 1.6.0, < 2.0)
34 | cocoapods-try (>= 1.1.0, < 2.0)
35 | colored2 (~> 3.1)
36 | escape (~> 0.0.4)
37 | fourflusher (>= 2.3.0, < 3.0)
38 | gh_inspector (~> 1.0)
39 | molinillo (~> 0.8.0)
40 | nap (~> 1.0)
41 | ruby-macho (>= 2.3.0, < 3.0)
42 | xcodeproj (>= 1.23.0, < 2.0)
43 | cocoapods-core (1.14.3)
44 | activesupport (>= 5.0, < 8)
45 | addressable (~> 2.8)
46 | algoliasearch (~> 1.0)
47 | concurrent-ruby (~> 1.1)
48 | fuzzy_match (~> 2.0.4)
49 | nap (~> 1.0)
50 | netrc (~> 0.11)
51 | public_suffix (~> 4.0)
52 | typhoeus (~> 1.0)
53 | cocoapods-deintegrate (1.0.5)
54 | cocoapods-downloader (2.1)
55 | cocoapods-plugins (1.0.0)
56 | nap
57 | cocoapods-search (1.0.1)
58 | cocoapods-trunk (1.6.0)
59 | nap (>= 0.8, < 2.0)
60 | netrc (~> 0.11)
61 | cocoapods-try (1.2.0)
62 | colored2 (3.1.2)
63 | concurrent-ruby (1.2.3)
64 | connection_pool (2.4.1)
65 | drb (2.2.0)
66 | ruby2_keywords
67 | escape (0.0.4)
68 | ethon (0.16.0)
69 | ffi (>= 1.15.0)
70 | ffi (1.16.3)
71 | fourflusher (2.3.1)
72 | fuzzy_match (2.0.4)
73 | gh_inspector (1.1.3)
74 | httpclient (2.8.3)
75 | i18n (1.14.1)
76 | concurrent-ruby (~> 1.0)
77 | json (2.7.1)
78 | minitest (5.21.2)
79 | molinillo (0.8.0)
80 | mutex_m (0.2.0)
81 | nanaimo (0.3.0)
82 | nap (1.1.0)
83 | netrc (0.11.0)
84 | public_suffix (4.0.7)
85 | rexml (3.2.6)
86 | ruby-macho (2.5.1)
87 | ruby2_keywords (0.0.5)
88 | typhoeus (1.4.1)
89 | ethon (>= 0.9.0)
90 | tzinfo (2.0.6)
91 | concurrent-ruby (~> 1.0)
92 | xcodeproj (1.23.0)
93 | CFPropertyList (>= 2.3.3, < 4.0)
94 | atomos (~> 0.1.3)
95 | claide (>= 1.0.2, < 2.0)
96 | colored2 (~> 3.1)
97 | nanaimo (~> 0.3.0)
98 | rexml (~> 3.2.4)
99 |
100 | PLATFORMS
101 | ruby
102 |
103 | DEPENDENCIES
104 | cocoapods (~> 1.14)!
105 |
106 | BUNDLED WITH
107 | 2.1.4
108 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Creating and Executing an Animation.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | import PlaygroundSupport
4 |
5 | /*:
6 |
7 | # Creating a Basic Animation in Stagehand
8 |
9 | Constructing an animation begins by building an `Animation` - a value type that is generic over the type of element
10 | that will be animated. For our example, we'll be animating a `UIView`. Our `Animation` struct holds all of the
11 | information about what our animation will do.
12 |
13 | To get started, let's create an animation and set its duration to 2 seconds.
14 |
15 | */
16 |
17 | import Stagehand
18 |
19 | var basicAnimation = Animation()
20 |
21 | basicAnimation.implicitDuration = 2
22 |
23 | /*:
24 |
25 | ## Using Keyframes
26 |
27 | The easiest way to add content to our animation is by adding keyframes. Keyframes let us interpolate properties between
28 | specified values over the course of the animation.
29 |
30 | */
31 |
32 | // Start out at full opacity, then fade to 50% at the halfway point, then back to full opacity by the end.
33 | basicAnimation.addKeyframe(for: \.alpha, at: 0.0, value: 1.0)
34 | basicAnimation.addKeyframe(for: \.alpha, at: 0.5, value: 0.5)
35 | basicAnimation.addKeyframe(for: \.alpha, at: 1.0, value: 1.0)
36 |
37 | /*:
38 |
39 | We can add keyframes for as many properties as we want to our animation. The order we add the keyframes doesn't matter.
40 |
41 | */
42 |
43 | // Start and end at the original size.
44 | basicAnimation.addKeyframe(for: \.transform, at: 0.0, value: .identity)
45 | basicAnimation.addKeyframe(for: \.transform, at: 1.0, value: .identity)
46 |
47 | // At the midpoint, increase the scale by 10%.
48 | basicAnimation.addKeyframe(for: \.transform, at: 0.5, value: .init(scaleX: 1.1, y: 1.1))
49 |
50 | /*:
51 |
52 | Keyframes have a lot of power (read the [All About Keyframes](All%20About%20Keyframes) page to get a taste of what all
53 | they can do), but there are also other things our animation can control. Check out the
54 | [Executing Code During Animations](Executing%20Code%20During%20Animations) to find out more.
55 |
56 | */
57 |
58 | /*:
59 |
60 | ## Executing Our Animation
61 |
62 | Now that we've constructed our animation, it's time to execute it on a view. Note that we haven't actually created our
63 | view yet! Stagehand allows for a separation of construction and execution, so we don't need to know what instance we'll
64 | be animating, only what the type will be.
65 |
66 | */
67 |
68 | let view = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100))
69 | view.backgroundColor = .red
70 | PlaygroundPage.current.liveView = WrapperView(wrappedView: view)
71 |
72 | /*:
73 |
74 | Now that we have our view ready to go, we can execute the animation. The simplest way to do this is using the
75 | `perform(on:delay:completion:)` method.
76 |
77 | */
78 |
79 | basicAnimation.perform(on: view)
80 |
81 | /*:
82 |
83 | Read on to the [Advanced Execution of Animations](Advanced%20Execution%20of%20Animations) page to read more about the
84 | power of separating construction and execution.
85 |
86 | */
87 |
88 | //: [Next](@next)
89 |
--------------------------------------------------------------------------------
/Example/Stagehand/AnimationCancelationViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 | import UIKit
19 |
20 | final class AnimationCancelationViewController: DemoViewController {
21 |
22 | // MARK: - Life Cycle
23 |
24 | override init() {
25 | super.init()
26 |
27 | contentView = mainView
28 |
29 | animationRows = [
30 | ("Animate", { [unowned self] in
31 | // Cancel any existing animation
32 | self.animationInstance?.cancel()
33 |
34 | let animation = self.makeAnimation()
35 | self.animationInstance = animation.perform(on: self.mainView.animatableView, duration: 2)
36 | }),
37 | ("Cancel (Revert)", { [unowned self] in
38 | self.animationInstance?.cancel(behavior: .revert)
39 | }),
40 | ("Cancel (Halt)", { [unowned self] in
41 | self.animationInstance?.cancel(behavior: .halt)
42 | }),
43 | ("Cancel (Complete)", { [unowned self] in
44 | self.animationInstance?.cancel(behavior: .complete)
45 | }),
46 | ]
47 | }
48 |
49 | // MARK: - Private Properties
50 |
51 | private let mainView: View = .init()
52 |
53 | private var animationInstance: AnimationInstance?
54 |
55 | // MARK: - Private Methods
56 |
57 | private func makeAnimation() -> Animation {
58 | var animation = Animation()
59 | animation.addKeyframe(for: \.transform, at: 0, value: .identity)
60 | animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: mainView.bounds.width - 100, y: 0))
61 | return animation
62 | }
63 |
64 | }
65 |
66 | // MARK: -
67 |
68 | extension AnimationCancelationViewController {
69 |
70 | final class View: UIView {
71 |
72 | // MARK: - Life Cycle
73 |
74 | override init(frame: CGRect) {
75 | super.init(frame: frame)
76 |
77 | animatableView.backgroundColor = .red
78 | addSubview(animatableView)
79 | }
80 |
81 | @available(*, unavailable)
82 | required init?(coder: NSCoder) {
83 | fatalError("init(coder:) has not been implemented")
84 | }
85 |
86 | // MARK: - Public Properties
87 |
88 | let animatableView: UIView = .init()
89 |
90 | // MARK: - UIView
91 |
92 | override func layoutSubviews() {
93 | animatableView.bounds.size = .init(width: 50, height: 50)
94 | animatableView.center = .init(
95 | x: bounds.minX + 50,
96 | y: bounds.height / 2
97 | )
98 | }
99 |
100 | }
101 |
102 | }
103 |
--------------------------------------------------------------------------------
/Example/Performance Tests/TransformPerformanceTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 | import XCTest
19 |
20 | final class TransformPerformanceTests: XCTestCase {
21 |
22 | func testCGAffineTransformInterpolationPerformance_identityToIdentity() {
23 | measure {
24 | for _ in 0...100 {
25 | for i in 0...10000 {
26 | let progress = Double(i) / 10000
27 | _ = CGAffineTransform.value(between: .identity, and: .identity, at: progress)
28 | }
29 | }
30 | }
31 | }
32 |
33 | func testCGAffineTransformInterpolationPerformance_complexTransforms() {
34 | measure {
35 | let fromTransform = CGAffineTransform.identity
36 | .rotated(by: 1)
37 | .scaledBy(x: 2, y: 3)
38 | .translatedBy(x: 4, y: 5)
39 |
40 | let toTransform = CGAffineTransform.identity
41 | .rotated(by: 6)
42 | .scaledBy(x: 7, y: 8)
43 | .translatedBy(x: 9, y: 10)
44 |
45 | for _ in 0...100 {
46 | for i in 0...10000 {
47 | let progress = Double(i) / 10000
48 | _ = CGAffineTransform.value(between: fromTransform, and: toTransform, at: progress)
49 | }
50 | }
51 | }
52 | }
53 |
54 | func testCATransform3DInterpolationPerformance_identityToIdentity() {
55 | measure {
56 | for _ in 0...100 {
57 | for i in 0...10000 {
58 | let progress = Double(i) / 10000
59 | _ = CATransform3D.value(between: CATransform3DIdentity, and: CATransform3DIdentity, at: progress)
60 | }
61 | }
62 | }
63 | }
64 |
65 | func testCATransform3DInterpolationPerformance_complexTransforms() {
66 | measure {
67 | var fromTransform = CATransform3DIdentity
68 | fromTransform.m34 = -0.05
69 | fromTransform = CATransform3DRotate(fromTransform, 1, 0, 0, 1)
70 | fromTransform = CATransform3DScale(fromTransform, 2, 3, 1)
71 | fromTransform = CATransform3DTranslate(fromTransform, 4, 5, 6)
72 |
73 | var toTransform = CATransform3DIdentity
74 | toTransform.m34 = -0.07
75 | toTransform = CATransform3DRotate(toTransform, 6, 0, 0, 1)
76 | toTransform = CATransform3DScale(toTransform, 7, 8, 9)
77 | toTransform = CATransform3DTranslate(toTransform, 10, 11, 12)
78 |
79 | for _ in 0...100 {
80 | for i in 0...10000 {
81 | let progress = Double(i) / 10000
82 | _ = CATransform3D.value(between: fromTransform, and: toTransform, at: progress)
83 | }
84 | }
85 | }
86 | }
87 |
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/Stagehand/AnimationCurve/AnimationCurve+Basic.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | public struct LinearAnimationCurve: AnimationCurve {
20 |
21 | public init() {}
22 |
23 | public func adjustedProgress(for progress: Double) -> Double {
24 | return progress
25 | }
26 |
27 | public func rawProgress(for adjustedProgress: Double) -> [Double] {
28 | return [adjustedProgress]
29 | }
30 |
31 | }
32 |
33 | // MARK: -
34 |
35 | /// A simple ease in curve.
36 | ///
37 | /// In general, cubic Bézier curves are preferred over parabolic curves as the smoother easing function; however, a
38 | /// parabolic curve is significantly simpler to calculate. It is therefore recommended to start by using a cubic Bézier
39 | /// curve (`CubicBezierAnimationCurve.easeIn`) and switch to a parabolic curve if performance becomes an issue.
40 | public struct ParabolicEaseInAnimationCurve: AnimationCurve {
41 |
42 | public init() {}
43 |
44 | public func adjustedProgress(for progress: Double) -> Double {
45 | return pow(progress, 2.0)
46 | }
47 |
48 | public func rawProgress(for adjustedProgress: Double) -> [Double] {
49 | return [sqrt(adjustedProgress)]
50 | }
51 |
52 | }
53 |
54 | // MARK: -
55 |
56 | /// A simple ease out curve.
57 | ///
58 | /// In general, cubic Bézier curves are preferred over parabolic curves as the smoother easing function; however, a
59 | /// parabolic curve is significantly simpler to calculate. It is therefore recommended to start by using a cubic Bézier
60 | /// curve (`CubicBezierAnimationCurve.easeOut`) and switch to a parabolic curve if performance becomes an issue.
61 | public struct ParabolicEaseOutAnimationCurve: AnimationCurve {
62 |
63 | public init() {}
64 |
65 | public func adjustedProgress(for progress: Double) -> Double {
66 | return 1 - pow(1 - progress, 2.0)
67 | }
68 |
69 | public func rawProgress(for adjustedProgress: Double) -> [Double] {
70 | return [1 - sqrt(1 - adjustedProgress)]
71 | }
72 |
73 | }
74 |
75 | // MARK: -
76 |
77 | /// A simple ease in ease out curve.
78 | ///
79 | /// In general, cubic Bézier curves are preferred over sinusoidal curves as the smoother easing function; however, a
80 | /// sinusoidal curve is significantly simpler to calculate. It is therefore recommended to start by using a cubic Bézier
81 | /// curve (`CubicBezierAnimationCurve.easeInEaseOut`) and switch to a sinusoidal curve if performance becomes an issue.
82 | public struct SinusoidalEaseInEaseOutAnimationCurve: AnimationCurve {
83 |
84 | public init() {}
85 |
86 | public func adjustedProgress(for progress: Double) -> Double {
87 | return 0.5 - cos(progress * .pi) * 0.5
88 | }
89 |
90 | public func rawProgress(for adjustedProgress: Double) -> [Double] {
91 | return [acos(1 - 2 * adjustedProgress) / .pi]
92 | }
93 |
94 | }
95 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Animation Curves.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | import PlaygroundSupport
4 | import Stagehand
5 |
6 | /*:
7 |
8 | # Animation Curves
9 |
10 | One of the easiest ways to make an animation feel more polished is by adding an animation curve. Animation curves
11 | control the way that properties will be interpolated.
12 |
13 | For sake of example, let's build an animation with the default (linear) animation curve.
14 |
15 | */
16 |
17 | var shakeAnimation = Animation()
18 |
19 | shakeAnimation.addKeyframe(for: \.transform, at: 0, value: .identity)
20 | shakeAnimation.addKeyframe(for: \.transform, at: 0.25, value: .init(translationX: 40, y: 0))
21 | shakeAnimation.addKeyframe(for: \.transform, at: 0.75, value: .init(translationX: -40, y: 0))
22 | shakeAnimation.addKeyframe(for: \.transform, at: 1, value: .identity)
23 |
24 | let view = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100))
25 | view.backgroundColor = .red
26 | PlaygroundPage.current.liveView = WrapperView(wrappedView: view, outset: 50)
27 |
28 | // This is the default value, but for the sake of explanation...
29 | shakeAnimation.curve = LinearAnimationCurve()
30 |
31 | let linearInstance = shakeAnimation.perform(on: view, delay: 1)
32 |
33 | /*:
34 |
35 | Run the playground up to this point to see what the default animation looks like.
36 |
37 | It gets the point across, but feels very flat and mechanical. Let's add some life to our animation by setting the curve
38 | to an ease-in ease-out curve. This means that the animation will start out slowly (ease in), speed up in the middle,
39 | then slow down at the end (ease out).
40 |
41 | */
42 |
43 | linearInstance.cancel()
44 |
45 | shakeAnimation.curve = CubicBezierAnimationCurve.easeInEaseOut
46 |
47 | shakeAnimation.perform(on: view, delay: 1)
48 |
49 | /*:
50 |
51 | Run the playground up to this point to see the difference.
52 |
53 | With the simple addition of an animation curve, our animation now feels much more fluid. Check out the "Animation
54 | Curves" screen in the demo app to see what each of the provided curves looks like.
55 |
56 | */
57 |
58 | /*:
59 |
60 | UIKit animations let you apply a curve to the whole animation. With the composibility Stagehand provides, you can apply
61 | different curves to different parts of an animation.
62 |
63 | */
64 |
65 | var childAnimation = Animation()
66 |
67 | childAnimation.addKeyframe(for: \.transform, at: 0, value: .identity)
68 | childAnimation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: 200, y: 0))
69 |
70 | var raceAnimation = Animation()
71 |
72 | // Animate the top view using a linear curve.
73 | raceAnimation.addChild(childAnimation, for: \.topView, startingAt: 0, relativeDuration: 1)
74 |
75 | // Apply the same animation to the bottom view, but using an ease in curve.
76 | childAnimation.curve = CubicBezierAnimationCurve.easeIn
77 | raceAnimation.addChild(childAnimation, for: \.bottomView, startingAt: 0, relativeDuration: 1)
78 |
79 | let raceCarView = RaceCarView(frame: .init(x: 0, y: 0, width: 300, height: 200))
80 | PlaygroundPage.current.liveView = raceCarView
81 |
82 | raceAnimation.perform(on: raceCarView, delay: 1)
83 |
84 | /*:
85 |
86 | ## Creating Custom Animation Curves
87 |
88 | Stagehand comes with a variety of animation curves built in, including the commonly used Cubic Bézier curve (with
89 | support for two control points). If you need a specific animation curve for your use case, you can define a new curve
90 | by conforming to the `AnimationCurve` protocol.
91 |
92 | */
93 |
94 | //: [Next](@next)
95 |
--------------------------------------------------------------------------------
/Example/Stagehand/AnimationGroupViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 | import UIKit
19 |
20 | final class AnimationGroupViewController: DemoViewController {
21 |
22 | // MARK: - Life Cycle
23 |
24 | override init() {
25 | super.init()
26 |
27 | contentView = View(topView: topView, bottomView: bottomView)
28 |
29 | animationRows = [
30 | ("Move Both Views", { [unowned self] in
31 | var animationGroup = AnimationGroup()
32 |
33 | let topAnimation = self.makeAnimation()
34 | animationGroup.addAnimation(topAnimation, for: self.topView, startingAt: 0, relativeDuration: 0.75)
35 |
36 | let bottomAnimation = self.makeAnimation()
37 | animationGroup.addAnimation(bottomAnimation, for: self.bottomView, startingAt: 0.25, relativeDuration: 0.75)
38 |
39 | animationGroup.perform(duration: 2)
40 | }),
41 | ]
42 | }
43 |
44 | // MARK: - Private Properties
45 |
46 | private let topView: UIView = .init()
47 |
48 | private let bottomView: UIView = .init()
49 |
50 | // MARK: - Private Methods
51 |
52 | private func makeAnimation() -> Animation {
53 | var animation = Animation()
54 | animation.addKeyframe(for: \.transform, at: 0, value: .identity)
55 | animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: contentView.bounds.width - 100, y: 0))
56 | return animation
57 | }
58 |
59 | }
60 |
61 | // MARK: -
62 |
63 | extension AnimationGroupViewController {
64 |
65 | final class View: UIView {
66 |
67 | // MARK: - Life Cycle
68 |
69 | init(topView: UIView, bottomView: UIView) {
70 | self.topView = topView
71 | self.bottomView = bottomView
72 |
73 | super.init(frame: .zero)
74 |
75 | topView.backgroundColor = .red
76 | addSubview(topView)
77 |
78 | bottomView.backgroundColor = .red
79 | addSubview(bottomView)
80 | }
81 |
82 | @available(*, unavailable)
83 | required init?(coder: NSCoder) {
84 | fatalError("init(coder:) has not been implemented")
85 | }
86 |
87 | // MARK: - Public Properties
88 |
89 | let topView: UIView
90 |
91 | let bottomView: UIView
92 |
93 | // MARK: - UIView
94 |
95 | override func layoutSubviews() {
96 | topView.bounds.size = .init(width: 50, height: 50)
97 | topView.center = .init(
98 | x: bounds.minX + 50,
99 | y: bounds.height / 3
100 | )
101 |
102 | bottomView.bounds.size = .init(width: 50, height: 50)
103 | bottomView.center = .init(
104 | x: bounds.minX + 50,
105 | y: bounds.height * 2 / 3
106 | )
107 | }
108 |
109 | }
110 |
111 | }
112 |
--------------------------------------------------------------------------------
/Example/Stagehand/DemoViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import UIKit
18 |
19 | class DemoViewController: UIViewController {
20 |
21 | // MARK: - Life Cycle
22 |
23 | init() {
24 | super.init(nibName: nil, bundle: nil)
25 |
26 | tableView.dataSource = self
27 | tableView.delegate = self
28 | view.addSubview(tableView)
29 |
30 | view.backgroundColor = .white
31 | }
32 |
33 | @available(*, unavailable)
34 | required init?(coder: NSCoder) {
35 | fatalError("init(coder:) has not been implemented")
36 | }
37 |
38 | // MARK: - Public Properties
39 |
40 | var animationRows: [(name: String, action: () -> Void)] = [] {
41 | didSet {
42 | tableView.reloadData()
43 | }
44 | }
45 |
46 | var contentView: UIView = .init() {
47 | didSet {
48 | oldValue.removeFromSuperview()
49 | view.addSubview(contentView)
50 | view.setNeedsLayout()
51 | }
52 | }
53 |
54 | var contentHeight: CGFloat = 200 {
55 | didSet {
56 | view.setNeedsLayout()
57 | }
58 | }
59 |
60 | // MARK: - Private Properties
61 |
62 | private let tableView: UITableView = .init()
63 |
64 | // MARK: - UIView
65 |
66 | override func viewDidLayoutSubviews() {
67 | super.viewDidLayoutSubviews()
68 |
69 | let topInset = [
70 | UIApplication.shared.statusBarFrame.height,
71 | navigationController?.navigationBar.frame.height,
72 | ].compactMap { $0 }.reduce(0, +)
73 |
74 | contentView.frame = CGRect(
75 | x: 0,
76 | y: topInset,
77 | width: view.bounds.width,
78 | height: contentHeight
79 | )
80 |
81 | tableView.frame = CGRect(
82 | x: 0,
83 | y: topInset + contentHeight,
84 | width: view.bounds.width,
85 | height: view.bounds.height - contentHeight - topInset
86 | )
87 | }
88 |
89 | }
90 |
91 | // MARK: -
92 |
93 | extension DemoViewController: UITableViewDataSource, UITableViewDelegate {
94 |
95 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
96 | return animationRows.count
97 | }
98 |
99 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
100 | return UITableViewCell(style: .default, reuseIdentifier: nil)
101 | }
102 |
103 | func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
104 | let row = animationRows[indexPath.row]
105 | cell.textLabel?.text = row.name
106 | cell.textLabel?.adjustsFontSizeToFitWidth = true
107 | }
108 |
109 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
110 | let row = animationRows[indexPath.row]
111 | row.action()
112 | tableView.deselectRow(at: indexPath, animated: true)
113 | }
114 |
115 | }
116 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Advanced Execution of Animations.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | import PlaygroundSupport
4 | import Stagehand
5 | import UIKit
6 |
7 | /*:
8 |
9 | # Advanced Execution of Animations
10 |
11 | We've already seen the basics of executing an animation, using the `perform(on:delay:completion:)` method.
12 |
13 | */
14 |
15 | let animation = AnimationFactory.makeBasicViewAnimation()
16 |
17 | let view = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100))
18 | view.backgroundColor = .red
19 | PlaygroundPage.current.liveView = WrapperView(wrappedView: view)
20 |
21 | animation.perform(
22 | on: view,
23 | completion: { success in
24 | if success {
25 | print("Our animation has successfully completed.")
26 | } else {
27 | print("Our animation has stopped running, but didn't finish.")
28 | }
29 | }
30 | )
31 |
32 | /*:
33 |
34 | ## Cancelling Our Animation
35 |
36 | When an animation is being performed, it holds onto the element it is animating weakly and will cancel itself if that
37 | element is deallocated. What if we need to stop the animation before that point though?
38 |
39 | The `perform(on:delay:completion:)` method returns an `AnimationInstance` that can be used to track and control the
40 | animation. We can use this animation instance to cancel the animation.
41 |
42 | */
43 |
44 | let animationInstance = animation.perform(on: view)
45 |
46 | animationInstance.cancel()
47 |
48 | /*:
49 |
50 | There are a few different behaviors you can use when cancelling an animation. The default is to halt the animation - to
51 | leave it in its current state when it was canceled. You can also revert the animation back to the starting point or
52 | jump to the final state. Check out the "Animation Cancellation" screen in the demo app to see how each of these work.
53 |
54 | Using the animation instance, we can also check the status of our animation at any point.
55 |
56 | */
57 |
58 | switch animationInstance.status {
59 | case .pending:
60 | print("Our animation hasn't started yet.")
61 |
62 | case let .animating(progress: progress):
63 | print("Our animation is \(progress * 100)% complete.")
64 |
65 | case .complete:
66 | print("Our animation completed successfully.")
67 |
68 | case let .canceled(behavior: behavior):
69 | print("Our animation was canceled using the \(behavior) behavior.")
70 | }
71 |
72 | /*:
73 |
74 | ## Adding a Delay
75 |
76 | What if we aren't ready to start our animation immediately? The `perform(on:delay:completion:)` method makes it easy to
77 | add a delay before our animation starts executing.
78 |
79 | */
80 |
81 | animation.perform(on: view, delay: 2)
82 |
83 | /*:
84 |
85 | One of the superpowers Stagehand has over `UIView` animations is the separation of construction and execution. Since we
86 | 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
87 | performed when we build it.
88 |
89 |
90 | ## Queueing Animations
91 |
92 | Sometimes we want to run animations in sequence. Stagehand provides the `AnimationQueue` as a way to do this. We create
93 | our animation queue targeting a specific element.
94 |
95 | When we enqueue the first animation, it will begin executing immediately. The next animation we enqueue will begin
96 | executing when the first one completes, or immediately if the first one hasn't already completed. `enqueue(animation:)`
97 | also returns an `AnimationInstance` so we can track and control each instance in the queue separately.
98 |
99 | */
100 |
101 | let animationQueue = AnimationQueue(element: view)
102 |
103 | animationQueue.enqueue(animation: animation)
104 |
105 | //: [Next](@next)
106 |
--------------------------------------------------------------------------------
/Example/Stagehand/ChildAnimationsWithCurvesViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 | import UIKit
19 |
20 | final class ChildAnimationsWithCurvesViewController: DemoViewController {
21 |
22 | // MARK: - Life Cycle
23 |
24 | override init() {
25 | super.init()
26 |
27 | contentView = mainView
28 |
29 | animationRows = [
30 | ("Reset", { [unowned self] in
31 | self.mainView.topView.transform = .identity
32 | self.mainView.bottomView.transform = .identity
33 | }),
34 | ("Linear / Ease In Ease Out", { [unowned self] in
35 | var animation = Animation()
36 |
37 | var topAnimation = self.makeAnimation()
38 | topAnimation.curve = LinearAnimationCurve()
39 | animation.addChild(topAnimation, for: \View.topView, startingAt: 0, relativeDuration: 1)
40 |
41 | var bottomAnimation = self.makeAnimation()
42 | bottomAnimation.curve = SinusoidalEaseInEaseOutAnimationCurve()
43 | animation.addChild(bottomAnimation, for: \View.bottomView, startingAt: 0, relativeDuration: 1)
44 |
45 | animation.perform(on: self.mainView, duration: 2)
46 | }),
47 | ]
48 | }
49 |
50 | // MARK: - Private Properties
51 |
52 | private let mainView: View = .init()
53 |
54 | // MARK: - Private Methods
55 |
56 | private func makeAnimation() -> Animation {
57 | var animation = Animation()
58 | animation.addKeyframe(for: \.transform, at: 0, value: .identity)
59 | animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: mainView.bounds.width - 100, y: 0))
60 | return animation
61 | }
62 |
63 | }
64 |
65 | // MARK: -
66 |
67 | extension ChildAnimationsWithCurvesViewController {
68 |
69 | final class View: UIView {
70 |
71 | // MARK: - Life Cycle
72 |
73 | override init(frame: CGRect) {
74 | super.init(frame: frame)
75 |
76 | topView.backgroundColor = .red
77 | addSubview(topView)
78 |
79 | bottomView.backgroundColor = .red
80 | addSubview(bottomView)
81 | }
82 |
83 | @available(*, unavailable)
84 | required init?(coder: NSCoder) {
85 | fatalError("init(coder:) has not been implemented")
86 | }
87 |
88 | // MARK: - Public Properties
89 |
90 | var topView: UIView = .init()
91 |
92 | var bottomView: UIView = .init()
93 |
94 | // MARK: - UIView
95 |
96 | override func layoutSubviews() {
97 | topView.bounds.size = .init(width: 50, height: 50)
98 | topView.center = .init(
99 | x: bounds.minX + 50,
100 | y: bounds.height / 3
101 | )
102 |
103 | bottomView.bounds.size = .init(width: 50, height: 50)
104 | bottomView.center = .init(
105 | x: bounds.minX + 50,
106 | y: bounds.height * 2 / 3
107 | )
108 | }
109 |
110 | }
111 |
112 | }
113 |
--------------------------------------------------------------------------------
/Example/Unit Tests/CGAffineTransformInterpolationTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 | import StagehandTesting
19 | import XCTest
20 |
21 | final class CGAffineTransformInterpolationTests: SnapshotTestCase {
22 |
23 | func testRotationAcrossBoundaries() {
24 | assertMidpoint(
25 | between: .init(rotationAngle: CGFloat.pi * 0.25),
26 | and: .init(rotationAngle: CGFloat.pi * 0.75),
27 | is: .init(rotationAngle: CGFloat.pi * 0.5)
28 | )
29 |
30 | assertMidpoint(
31 | between: .init(rotationAngle: CGFloat.pi * 0.75),
32 | and: .init(rotationAngle: CGFloat.pi * -0.75),
33 | is: .init(rotationAngle: CGFloat.pi)
34 | )
35 |
36 | assertMidpoint(
37 | between: .init(rotationAngle: CGFloat.pi * -0.75),
38 | and: .init(rotationAngle: CGFloat.pi * -0.25),
39 | is: .init(rotationAngle: CGFloat.pi * -0.5)
40 | )
41 |
42 | assertMidpoint(
43 | between: .init(rotationAngle: CGFloat.pi * -0.25),
44 | and: .init(rotationAngle: CGFloat.pi * 0.25),
45 | is: .identity
46 | )
47 |
48 | assertMidpoint(
49 | between: .init(rotationAngle: CGFloat.pi * 0.25),
50 | and: .init(rotationAngle: CGFloat.pi * -0.25),
51 | is: .identity
52 | )
53 |
54 | assertMidpoint(
55 | between: .init(rotationAngle: CGFloat.pi * -0.25),
56 | and: .init(rotationAngle: CGFloat.pi * -0.75),
57 | is: .init(rotationAngle: CGFloat.pi * -0.5)
58 | )
59 |
60 | assertMidpoint(
61 | between: .init(rotationAngle: CGFloat.pi * -0.75),
62 | and: .init(rotationAngle: CGFloat.pi * -1.25),
63 | is: .init(rotationAngle: CGFloat.pi)
64 | )
65 |
66 | assertMidpoint(
67 | between: .init(rotationAngle: CGFloat.pi * -1.5),
68 | and: .init(rotationAngle: CGFloat.pi * -2),
69 | is: .init(rotationAngle: CGFloat.pi * 0.25)
70 | )
71 | }
72 |
73 | // MARK: - Helper Methods
74 |
75 | private func assertMidpoint(
76 | between initialTransform: CGAffineTransform,
77 | and finalTransform: CGAffineTransform,
78 | is expectedMidpointTransform: CGAffineTransform,
79 | accuracy: CGFloat = 1e-15,
80 | file: StaticString = #file,
81 | line: UInt = #line
82 | ) {
83 | let actualMidpointTransform = CGAffineTransform.value(between: initialTransform, and: finalTransform, at: 0.5)
84 |
85 | guard
86 | abs(expectedMidpointTransform.a - actualMidpointTransform.a) <= accuracy,
87 | abs(expectedMidpointTransform.b - actualMidpointTransform.b) <= accuracy,
88 | abs(expectedMidpointTransform.c - actualMidpointTransform.c) <= accuracy,
89 | abs(expectedMidpointTransform.d - actualMidpointTransform.d) <= accuracy,
90 | abs(expectedMidpointTransform.tx - actualMidpointTransform.tx) <= accuracy,
91 | abs(expectedMidpointTransform.ty - actualMidpointTransform.ty) <= accuracy
92 | else {
93 | XCTFail("\(expectedMidpointTransform) is not equal to \(actualMidpointTransform)", file: file, line: line)
94 | return
95 | }
96 | }
97 |
98 | }
99 |
--------------------------------------------------------------------------------
/Example/Stagehand/AnimationQueueViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 | import UIKit
19 |
20 | final class AnimationQueueViewController: DemoViewController {
21 |
22 | // MARK: - Life Cycle
23 |
24 | override init() {
25 | self.animationQueue = AnimationQueue(element: mainView)
26 |
27 | super.init()
28 |
29 | contentView = mainView
30 | contentHeight = 300
31 |
32 | animationRows = [
33 | ("Cancel Pending Animations", { [unowned self] in
34 | self.animationQueue.cancelPendingAnimations()
35 | }),
36 | ("Enqueue Move to Center", { [unowned self] in
37 | let animation = self.makeTranslationAnimation(x: 0, y: 0)
38 | self.animationQueue.enqueue(animation: animation)
39 | }),
40 | ("Enqueue Move to Top Left", { [unowned self] in
41 | let animation = self.makeTranslationAnimation(x: -100, y: -100)
42 | self.animationQueue.enqueue(animation: animation)
43 | }),
44 | ("Enqueue Move to Top Right", { [unowned self] in
45 | let animation = self.makeTranslationAnimation(x: 100, y: -100)
46 | self.animationQueue.enqueue(animation: animation)
47 | }),
48 | ("Enqueue Move to Bottom Left", { [unowned self] in
49 | let animation = self.makeTranslationAnimation(x: -100, y: 100)
50 | self.animationQueue.enqueue(animation: animation)
51 | }),
52 | ("Enqueue Move to Bottom Right", { [unowned self] in
53 | let animation = self.makeTranslationAnimation(x: 100, y: 100)
54 | self.animationQueue.enqueue(animation: animation)
55 | }),
56 | ]
57 | }
58 |
59 | // MARK: - Private Properties
60 |
61 | private let mainView: View = .init()
62 |
63 | private let animationQueue: AnimationQueue
64 |
65 | // MARK: - Private Methods
66 |
67 | private func makeTranslationAnimation(x: CGFloat, y: CGFloat) -> Animation {
68 | var animation = Animation()
69 | animation.implicitDuration = 2
70 |
71 | animation.addKeyframe(for: \.animatableView.transform, at: 0, relativeValue: { $0 })
72 | animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: x, y: y))
73 |
74 | return animation
75 | }
76 |
77 | }
78 |
79 | // MARK: -
80 |
81 | extension AnimationQueueViewController {
82 |
83 | final class View: UIView {
84 |
85 | // MARK: - Life Cycle
86 |
87 | override init(frame: CGRect) {
88 | super.init(frame: frame)
89 |
90 | animatableView.bounds.size = .init(width: 40, height: 40)
91 | animatableView.backgroundColor = .red
92 | addSubview(animatableView)
93 | }
94 |
95 | @available(*, unavailable)
96 | required init?(coder: NSCoder) {
97 | fatalError("init(coder:) has not been implemented")
98 | }
99 |
100 | // MARK: - Public Properties
101 |
102 | let animatableView: UIView = .init()
103 |
104 | // MARK: - UIView
105 |
106 | override func layoutSubviews() {
107 | animatableView.center = .init(
108 | x: (bounds.maxX - bounds.minX) / 2,
109 | y: (bounds.maxY - bounds.minY) / 2
110 | )
111 | }
112 |
113 | }
114 |
115 | }
116 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/All About Keyframes.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | import Stagehand
4 |
5 | /*:
6 |
7 | ## Defining Keyframes
8 |
9 | Stagehands uses keyframes in the traditional sense of the term in the animation world. That is, a keyframe represents
10 | the value for a specific property at a specific point in time. For each frame in between the keyframes, the value for
11 | that property will be interpolated between the values defined by the keyframes. Frames before the first keyframe will
12 | use the value of that keyframe; likewise with frames after the last keyframe.
13 |
14 | Since our `Animation` is generic over the type of element we are animating, we can use the power of Swift key paths to
15 | animate any property that can be interpolated. One of the most common use cases for this is adding keyframes for
16 | properties of subviews on a custom `UIView` subclass.
17 |
18 | */
19 |
20 | final class MySpecialView: UIView {
21 |
22 | let leftView: UIView = .init()
23 |
24 | let rightView: UIView = .init()
25 |
26 | }
27 |
28 | var fadeFromLeftToRightAnimation = Animation()
29 |
30 | // Over the first half of the animation, fade out the left view.
31 | fadeFromLeftToRightAnimation.addKeyframe(for: \.leftView.alpha, at: 0.0, value: 1)
32 | fadeFromLeftToRightAnimation.addKeyframe(for: \.leftView.alpha, at: 0.5, value: 0)
33 |
34 | // Over the second half of the animation, fade in the right view.
35 | fadeFromLeftToRightAnimation.addKeyframe(for: \.rightView.alpha, at: 0.5, value: 0)
36 | fadeFromLeftToRightAnimation.addKeyframe(for: \.rightView.alpha, at: 1.0, value: 1)
37 |
38 | /*:
39 |
40 | 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
41 | animation. Since the last keyframe for the left view has a value of `0`, the alpha will remain there through the rest
42 | of the animation. Since the first keyframe for the right view has a value of `0`, it will start there. Then over the
43 | second half of the animation it will fade in.
44 |
45 | With only two subviews, this is easy to read - but what happens when we start adding in more subviews? Or more
46 | properties to animate? Check out the [Composing Animations](Composing%20Animations) page to see how we can make
47 | multi-part animations like this one easier to reason about.
48 |
49 | */
50 |
51 | /*:
52 |
53 | ## Making Keyframes Relative to the Initial Value
54 |
55 | So far we've seen keyframes define fixed values at each timestamp. Sometimes, to make our animations more reusable, we
56 | need to define the keyframes relative to the value of the property at the beginning of the animation. To do this, we
57 | can use relative keyframes, which take a closure that transforms the initial value of the property into the value for
58 | the property at that keyframe.
59 |
60 | */
61 |
62 | var rotateAnimation = Animation()
63 |
64 | // Rotate the view 90 degrees from its current position.
65 | rotateAnimation.addKeyframe(for: \.transform, at: 0, relativeValue: { $0 })
66 | rotateAnimation.addKeyframe(for: \.transform, at: 1, relativeValue: { $0.rotated(by: .pi / 4) })
67 |
68 | /*:
69 |
70 | Keyframes with static and relative values can be mixed together, even for the same property.
71 |
72 | A common use case for this is having a property start the animation at its current value, before being animated to a
73 | new (fixed) value.
74 |
75 | */
76 |
77 | var fadeOutAnimation = Animation()
78 |
79 | // Fade the view from its current alpha down to 0.
80 | fadeOutAnimation.addKeyframe(for: \.alpha, at: 0, relativeValue: { $0 })
81 | fadeOutAnimation.addKeyframe(for: \.alpha, at: 1, value: 0)
82 |
83 | /*:
84 |
85 | ## Animating Different Types of Properties
86 |
87 | Stagehand ships with support for animating properties of a variety of common types. This includes floating point types,
88 | many of the CoreGraphics geometric types (`CGPoint`, `CGSize`, etc.), colors, etc. These aren't the only types you can
89 | add keyframes for, however - check out the [Animating Custom Properties](Animating%20Custom%20Properties) page for more
90 | on how to support animating properties of custom types.
91 |
92 | */
93 |
94 | //: [Next](@next)
95 |
--------------------------------------------------------------------------------
/Example/Unit Tests/AnimationCurveSnapshotTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 | import StagehandTesting
19 |
20 | final class AnimationCurveSnapshotTests: SnapshotTestCase {
21 |
22 | // MARK: - Tests
23 |
24 | func testLinear() {
25 | verifyGraphView(for: LinearAnimationCurve())
26 | }
27 |
28 | func testParabolicEaseIn() {
29 | verifyGraphView(for: ParabolicEaseInAnimationCurve())
30 | }
31 |
32 | func testParabolicEaseOut() {
33 | verifyGraphView(for: ParabolicEaseOutAnimationCurve())
34 | }
35 |
36 | func testSinusoidalEaseInEaseOut() {
37 | verifyGraphView(for: SinusoidalEaseInEaseOutAnimationCurve())
38 | }
39 |
40 | func testCubicBezierEaseIn() {
41 | verifyGraphView(for: CubicBezierAnimationCurve.easeIn)
42 | }
43 |
44 | func testCubicBezierEaseOut() {
45 | verifyGraphView(for: CubicBezierAnimationCurve.easeOut)
46 | }
47 |
48 | func testCubicBezierEaseInEaseOut() {
49 | verifyGraphView(for: CubicBezierAnimationCurve.easeInEaseOut)
50 | }
51 |
52 | func testCubicBezierOvershoot() {
53 | verifyGraphView(for: CubicBezierAnimationCurve(controlPoints: (0.5, 0.0), (0.5, 1.3)))
54 | }
55 |
56 | // MARK: - Private Methods
57 |
58 | private func verifyGraphView(for curve: AnimationCurve, file: StaticString = #file, line: UInt = #line) {
59 | let graphSize: CGFloat = 200
60 | let margin: CGFloat = 20
61 | let containerSize = graphSize + margin * 2
62 | let containerView: UIView = .init(frame: .init(x: 0, y: 0, width: containerSize, height: containerSize))
63 | containerView.backgroundColor = .white
64 |
65 | let graphView: UIView = .init(frame: .init(x: margin, y: margin, width: graphSize, height: graphSize))
66 | let gridLayer = makeGridLayer(frame: graphView.bounds)
67 | graphView.layer.addSublayer(gridLayer)
68 | let curveLayer = makeCurveLayer(frame: graphView.bounds, curve: curve)
69 | graphView.layer.addSublayer(curveLayer)
70 | containerView.addSubview(graphView)
71 |
72 | FBSnapshotVerifyView(containerView, file: file, line: line)
73 | }
74 |
75 | private func makeGridLayer(frame: CGRect) -> CAShapeLayer {
76 | let gridLayer: CAShapeLayer = .init()
77 | gridLayer.strokeColor = UIColor(white: 0.9, alpha: 1).cgColor
78 | gridLayer.lineWidth = 1
79 | gridLayer.frame = frame
80 | ShapeLayerUtils.addGridPath(to: gridLayer, rows: 8, columns: 8)
81 | return gridLayer
82 | }
83 |
84 | private func makeCurveLayer(frame: CGRect, curve: AnimationCurve) -> CAShapeLayer {
85 | let curveLayer: CAShapeLayer = .init()
86 | curveLayer.strokeColor = UIColor.black.cgColor
87 | curveLayer.lineWidth = 2
88 | curveLayer.fillColor = nil
89 | curveLayer.frame = frame
90 |
91 | let path: CGMutablePath = .init()
92 | let height = frame.size.height
93 | path.move(to: CGPoint(x: 0, y: height))
94 | for uncurvedProgress in stride(from: 0, through: 1, by: 0.01) {
95 | let curvedProgress = curve.adjustedProgress(for: uncurvedProgress)
96 | path.addLine(
97 | to: CGPoint(
98 | x: uncurvedProgress * Double(frame.size.width),
99 | y: Double(height) - curvedProgress * Double(height)
100 | )
101 | )
102 | }
103 | curveLayer.path = path
104 | return curveLayer
105 | }
106 |
107 | }
108 |
--------------------------------------------------------------------------------
/Example/Stagehand/AnimationFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 |
19 | enum AnimationFactory {
20 |
21 | static func makeFadeOutAnimation() -> Animation {
22 | var fadeOutAnimation = Animation()
23 | fadeOutAnimation.addKeyframe(for: \.alpha, at: 0, value: 1)
24 | fadeOutAnimation.addKeyframe(for: \.alpha, at: 1, value: 0)
25 | fadeOutAnimation.addKeyframe(for: \.transform, at: 0, value: .identity)
26 | fadeOutAnimation.addKeyframe(for: \.transform, at: 1, value: .init(scaleX: 1.1, y: 1.1))
27 | return fadeOutAnimation
28 | }
29 |
30 | static func makeFadeInAnimation() -> Animation {
31 | var fadeInAnimation = Animation()
32 | fadeInAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
33 | fadeInAnimation.addKeyframe(for: \.alpha, at: 1, value: 1)
34 | fadeInAnimation.addKeyframe(for: \.transform, at: 0, value: .init(scaleX: 1.1, y: 1.1))
35 | fadeInAnimation.addKeyframe(for: \.transform, at: 1, value: .identity)
36 | return fadeInAnimation
37 | }
38 |
39 | static func makeResetTransformAnimation() -> Animation {
40 | var resetAnimation = Animation()
41 | resetAnimation.addKeyframe(for: \.transform, at: 0, relativeValue: { $0 })
42 | resetAnimation.addKeyframe(for: \.transform, at: 1, value: .identity)
43 | return resetAnimation
44 | }
45 |
46 | static func makeRotateAnimation() -> Animation {
47 | var rotateAnimation = Animation()
48 | rotateAnimation.addKeyframe(for: \.transform, at: 0, relativeValue: { $0 })
49 | rotateAnimation.addKeyframe(for: \.transform, at: 1, relativeValue: { $0.rotated(by: .pi / 4) })
50 | return rotateAnimation
51 | }
52 |
53 | static func makePopAnimation() -> Animation {
54 | var popAnimation = Animation()
55 | popAnimation.addKeyframe(for: \.transform, at: 0, relativeValue: { $0 })
56 | popAnimation.addKeyframe(for: \.transform, at: 0.1, relativeValue: { $0.scaledBy(x: 0.9, y: 0.9) })
57 | popAnimation.addKeyframe(for: \.transform, at: 0.5, relativeValue: { $0.scaledBy(x: 1.5, y: 1.5) })
58 | popAnimation.addKeyframe(for: \.transform, at: 0.9, relativeValue: { $0.scaledBy(x: 0.9, y: 0.9) })
59 | popAnimation.addKeyframe(for: \.transform, at: 1, relativeValue: { $0 })
60 | return popAnimation
61 | }
62 |
63 | static func makeSkewAnimation() -> Animation {
64 | var popAnimation = Animation()
65 | popAnimation.addKeyframe(for: \.transform, at: 0, relativeValue: { $0 })
66 | popAnimation.addKeyframe(
67 | for: \.transform,
68 | at: 1,
69 | relativeValue: { transform in
70 | let skewTransform = CGAffineTransform(
71 | a: 1,
72 | b: 0,
73 | c: 0.2,
74 | d: 1,
75 | tx: 0,
76 | ty: 0
77 | )
78 | return transform.concatenating(skewTransform)
79 | }
80 | )
81 | return popAnimation
82 | }
83 |
84 | static func makeGhostAnimation() -> Animation {
85 | var ghostAnimation = Animation()
86 | ghostAnimation.addKeyframe(for: \.alpha, at: 0, relativeValue: { $0 })
87 | ghostAnimation.addKeyframe(for: \.alpha, at: 0.25, value: 0)
88 | ghostAnimation.addKeyframe(for: \.alpha, at: 0.5, relativeValue: { $0 })
89 | ghostAnimation.addKeyframe(for: \.alpha, at: 0.75, value: 0)
90 | ghostAnimation.addKeyframe(for: \.alpha, at: 0, relativeValue: { $0 })
91 | return ghostAnimation
92 | }
93 |
94 | }
95 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Animating Custom Properties.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | /*:
4 |
5 | # Animating Custom Properties
6 |
7 | Stagehand comes with the ability to animate a wide variety of common property types out of the box. Any property of one
8 | of these types can be animated using keyframes. Sometimes we need to animate custom types though.
9 |
10 | As an example, let's define a `Border` value type that wraps the border-related properties of a view:
11 |
12 | */
13 |
14 | import UIKit
15 |
16 | struct Border {
17 |
18 | var width: CGFloat
19 |
20 | var color: UIColor?
21 |
22 | static let none: Border = .init(width: 0, color: nil)
23 |
24 | }
25 |
26 | extension UIView {
27 |
28 | var border: Border {
29 | get {
30 | return .init(
31 | width: layer.borderWidth,
32 | color: layer.borderColor.map(UIColor.init)
33 | )
34 | }
35 | set {
36 | layer.borderWidth = newValue.width
37 | layer.borderColor = newValue.color?.cgColor
38 | }
39 | }
40 |
41 | }
42 |
43 | /*:
44 |
45 | In its current form, we can't add keyframes for the `\UIView.border` property, since we don't know how to interpolate
46 | the values. Fortunately, all we need to do is make `Border` conform to the `AnimatableProperty` protocol. This
47 | conformance tells Stagehand how to interpolate between two values of the given type.
48 |
49 | Since our `Border` type is made up of properties that we do know how to animate, we can simply create a new `Border`
50 | value with each of the properties interpolated.
51 |
52 | */
53 |
54 | import Stagehand
55 |
56 | extension Border: AnimatableProperty {
57 |
58 | static func value(between initialValue: Border, and finalValue: Border, at progress: Double) -> Border {
59 | return .init(
60 | width: CGFloat.value(between: initialValue.width, and: finalValue.width, at: progress),
61 | color: UIColor.optionalValue(between: initialValue.color, and: finalValue.color, at: progress)
62 | )
63 | }
64 |
65 | }
66 |
67 | /*:
68 |
69 | Now that we know how to interpolate our `Border` type, we can add it to an animation.
70 |
71 | */
72 |
73 | var animation = Animation()
74 |
75 | animation.addKeyframe(for: \.border, at: 0, relativeValue: { $0 })
76 | animation.addKeyframe(for: \.border, at: 1, value: .none)
77 |
78 | /*:
79 |
80 | ## Interpolating Optional Values
81 |
82 | This works great when our property is non-optional. But what happens when the value could be `nil`? The expected
83 | behavior isn't always obvious, so Stagehand disables this by default. If you want to animate optional values, you can
84 | enable this by conforming to the `AnimatableOptionalProperty` protocol.
85 |
86 | As an example, let's add the ability to animate between two different `Border?` values. What's between a `nil` border
87 | 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
88 | same color as the other value. This will enable us to animate in/out our border where only the width changes.
89 |
90 | */
91 |
92 | extension Border: AnimatableOptionalProperty {
93 |
94 | static func optionalValue(between initialValue: Border?, and finalValue: Border?, at progress: Double) -> Border? {
95 | guard progress > 0 else {
96 | return initialValue
97 | }
98 |
99 | guard progress < 1 else {
100 | return finalValue
101 | }
102 |
103 | switch (initialValue, finalValue) {
104 | case (nil, nil):
105 | return nil
106 |
107 | case let (.some(initialValue), .some(finalValue)):
108 | return Border.value(between: initialValue, and: finalValue, at: progress)
109 |
110 | case let (nil, .some(finalValue)):
111 | return Border.value(
112 | between: .init(width: 0, color: finalValue.color),
113 | and: finalValue,
114 | at: progress
115 | )
116 |
117 | case let (.some(initialValue), nil):
118 | return Border.value(
119 | between: initialValue,
120 | and: .init(width: 0, color: initialValue.color),
121 | at: progress
122 | )
123 | }
124 | }
125 |
126 | }
127 |
128 | //: [Next](@next)
129 |
--------------------------------------------------------------------------------
/Example/Stagehand/ExecutionBlockViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 | import UIKit
19 |
20 | final class ExecutionBlockViewController: DemoViewController {
21 |
22 | // MARK: - Life Cycle
23 |
24 | override init() {
25 | super.init()
26 |
27 | contentView = mainView
28 |
29 | animationRows = [
30 | ("Color Change with Haptic Feedback", { [unowned self] in
31 | self.reset()
32 |
33 | let feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
34 |
35 | var animation = self.makeAnimation()
36 | animation.addExecution(
37 | onForward: { view in
38 | view.backgroundColor = .yellow
39 | feedbackGenerator.impactOccurred()
40 | },
41 | onReverse: { view in
42 | view.backgroundColor = .red
43 | feedbackGenerator.impactOccurred()
44 | },
45 | at: 0.33
46 | )
47 | animation.addExecution(
48 | onForward: { view in
49 | view.backgroundColor = .green
50 | feedbackGenerator.impactOccurred()
51 | },
52 | onReverse: { view in
53 | view.backgroundColor = .yellow
54 | feedbackGenerator.impactOccurred()
55 | },
56 | at: 0.66
57 | )
58 | animation.addExecution(
59 | onForward: { _ in
60 | feedbackGenerator.impactOccurred()
61 | },
62 | onReverse: { _ in
63 | // No-op. Only use haptics on the forward direction, otherwise it will execute twice at the end.
64 | // (once when it hits the end going forward, then immediately after when it starts the reverse
65 | // animation cycle).
66 | },
67 | at: 1
68 | )
69 |
70 | feedbackGenerator.prepare()
71 | self.animationInstance = animation.perform(
72 | on: self.mainView.animatableView,
73 | duration: 2,
74 | repeatStyle: .repeating(count: 2, autoreversing: true)
75 | )
76 | }),
77 | ]
78 | }
79 |
80 | // MARK: - Private Properties
81 |
82 | private let mainView: View = .init()
83 |
84 | private var animationInstance: AnimationInstance?
85 |
86 | // MARK: - Private Methods
87 |
88 | private func makeAnimation() -> Animation {
89 | var animation = Animation()
90 | animation.addKeyframe(for: \.transform, at: 0, value: .identity)
91 | animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: mainView.bounds.width - 100, y: 0))
92 | return animation
93 | }
94 |
95 | private func reset() {
96 | animationInstance?.cancel()
97 | animationInstance = nil
98 |
99 | mainView.animatableView.transform = .identity
100 | }
101 |
102 | }
103 |
104 | // MARK: -
105 |
106 | extension ExecutionBlockViewController {
107 |
108 | final class View: UIView {
109 |
110 | // MARK: - Life Cycle
111 |
112 | override init(frame: CGRect) {
113 | super.init(frame: frame)
114 |
115 | animatableView.backgroundColor = .red
116 | addSubview(animatableView)
117 | }
118 |
119 | @available(*, unavailable)
120 | required init?(coder: NSCoder) {
121 | fatalError("init(coder:) has not been implemented")
122 | }
123 |
124 | // MARK: - Public Properties
125 |
126 | let animatableView: UIView = .init()
127 |
128 | // MARK: - UIView
129 |
130 | override func layoutSubviews() {
131 | animatableView.bounds.size = .init(width: 50, height: 50)
132 | animatableView.center = .init(
133 | x: bounds.minX + 50,
134 | y: bounds.height / 2
135 | )
136 | }
137 |
138 | }
139 |
140 | }
141 |
--------------------------------------------------------------------------------
/Example/Stagehand/ColorAnimationsViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Stagehand
18 | import UIKit
19 |
20 | final class ColorAnimationsViewController: DemoViewController {
21 |
22 | // MARK: - Life Cycle
23 |
24 | override init() {
25 | super.init()
26 |
27 | contentView = .init()
28 | contentView.backgroundColor = .red
29 |
30 | animationRows = [
31 | ("Reset to Red (sRGB)", { [unowned self] in
32 | self.animationInstance?.cancel()
33 | self.contentView.backgroundColor = .red
34 | }),
35 | ("Reset to Red (P3)", { [unowned self] in
36 | self.animationInstance?.cancel()
37 | self.contentView.backgroundColor = UIColor(displayP3Red: 1, green: 0, blue: 0, alpha: 1)
38 | }),
39 | ("Red (sRGB) -> Green (sRGB)", { [unowned self] in
40 | self.animationInstance?.cancel()
41 |
42 | var animation = Animation()
43 |
44 | animation.addKeyframe(for: \.backgroundColor, at: 0, value: .red)
45 | animation.addKeyframe(for: \.backgroundColor, at: 1, value: .green)
46 |
47 | self.animationInstance = animation.perform(on: self.contentView, duration: 2)
48 | }),
49 | ("Red (sRGB) -> nil -> Green (sRGB)", { [unowned self] in
50 | self.animationInstance?.cancel()
51 |
52 | var animation = Animation()
53 |
54 | animation.addKeyframe(for: \.backgroundColor, at: 0, value: .red)
55 | animation.addKeyframe(for: \.backgroundColor, at: 0.5, value: nil)
56 | animation.addKeyframe(for: \.backgroundColor, at: 1, value: .green)
57 |
58 | self.animationInstance = animation.perform(on: self.contentView, duration: 2)
59 | }),
60 | ("Red (P3) -> Green (P3)", { [unowned self] in
61 | self.animationInstance?.cancel()
62 |
63 | var animation = Animation()
64 |
65 | animation.addKeyframe(for: \UIView.backgroundColor, at: 0, value: UIColor(displayP3Red: 1, green: 0, blue: 0, alpha: 1))
66 | animation.addKeyframe(for: \UIView.backgroundColor, at: 1, value: UIColor(displayP3Red: 0, green: 1, blue: 0, alpha: 1))
67 |
68 | self.animationInstance = animation.perform(on: self.contentView, duration: 2)
69 | }),
70 | ("Red (sRGB) -> Green (P3)", { [unowned self] in
71 | self.animationInstance?.cancel()
72 |
73 | var animation = Animation()
74 |
75 | animation.addKeyframe(for: \UIView.backgroundColor, at: 0, value: .red)
76 | animation.addKeyframe(for: \UIView.backgroundColor, at: 1, value: UIColor(displayP3Red: 0, green: 1, blue: 0, alpha: 1))
77 |
78 | self.animationInstance = animation.perform(on: self.contentView, duration: 2)
79 | }),
80 | ("Red (sRGB) -> Red (P3)", { [unowned self] in
81 | self.animationInstance?.cancel()
82 |
83 | var animation = Animation()
84 |
85 | animation.addKeyframe(for: \UIView.backgroundColor, at: 0, value: .red)
86 | animation.addKeyframe(for: \UIView.backgroundColor, at: 1, value: UIColor(displayP3Red: 1, green: 0, blue: 0, alpha: 1))
87 |
88 | self.animationInstance = animation.perform(on: self.contentView, duration: 2)
89 | }),
90 | ("Red (sRGB), with alpha 1 -> 0.5 -> 1", { [unowned self] in
91 | self.animationInstance?.cancel()
92 |
93 | var animation = Animation()
94 |
95 | animation.addKeyframe(for: \UIView.backgroundColor, at: 0.0, value: UIColor.red)
96 | animation.addKeyframe(for: \UIView.backgroundColor, at: 0.5, value: UIColor.red.withAlphaComponent(0.5))
97 | animation.addKeyframe(for: \UIView.backgroundColor, at: 1.0, value: UIColor.red)
98 |
99 | self.animationInstance = animation.perform(on: self.contentView, duration: 2)
100 | }),
101 | ]
102 | }
103 |
104 | // MARK: - Private Properties
105 |
106 | private var animationInstance: AnimationInstance?
107 |
108 | }
109 |
--------------------------------------------------------------------------------
/Example/Stagehand/RootViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import UIKit
18 |
19 | final class RootViewController: UITableViewController {
20 |
21 | // MARK: - Life Cycle
22 |
23 | init() {
24 | super.init(style: .plain)
25 |
26 | navigationItem.title = "Stagehand Demo"
27 | }
28 |
29 | @available(*, unavailable)
30 | required init?(coder: NSCoder) {
31 | fatalError("init(coder:) has not been implemented")
32 | }
33 |
34 | // MARK: - Private Properties
35 |
36 | private typealias RowModel = (name: String, viewControllerFactory: () -> UIViewController)
37 |
38 | /// Screens that show an example of a complete animation.
39 | private let demoScreens: [RowModel] = [
40 | ]
41 |
42 | /// Screens that show how a specific feature can be used.
43 | private let featureScreens: [RowModel] = [
44 | ("Simple Keyframe Animations", { SimpleAnimationsViewController() }),
45 | ("Relative Keyframe Animations", { RelativeAnimationsViewController() }),
46 | ("Color Keyframe Animations", { ColorAnimationsViewController() }),
47 | ("Child Animations", { ChildAnimationsViewController() }),
48 | ("Animation Curves", { AnimationCurveViewController() }),
49 | ("Child Animations with Curves", { ChildAnimationsWithCurvesViewController() }),
50 | ("Animation Cancellation", { AnimationCancelationViewController() }),
51 | ("Property Assignments", { PropertyAssignmentViewController() }),
52 | ("Repeating Animations", { RepeatingAnimationsViewController() }),
53 | ("Execution Blocks", { ExecutionBlockViewController() }),
54 | ("Animation Groups", { AnimationGroupViewController() }),
55 | ("Animation Queues", { AnimationQueueViewController() }),
56 | ]
57 |
58 | /// Screens that are used for debugging specific functionality.
59 | private let debuggingScreens: [RowModel] = [
60 | ("Child Animation Progress", { ChildAnimationProgressViewController() }),
61 | ("Performance Benchmark", { PerformanceBenchmarkViewController() }),
62 | ("CGAffineTransform Debugging", { CGAffineTransformDebuggingViewController() }),
63 | ]
64 |
65 | // MARK: - UITableViewController
66 |
67 | override func numberOfSections(in tableView: UITableView) -> Int {
68 | return Section.allCases.count
69 | }
70 |
71 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
72 | return rows(for: Section(rawValue: section)!).count
73 | }
74 |
75 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
76 | return UITableViewCell(style: .default, reuseIdentifier: nil)
77 | }
78 |
79 | override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
80 | let screen = rows(for: Section(rawValue: indexPath.section)!)[indexPath.row]
81 | cell.textLabel?.text = screen.name
82 | }
83 |
84 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
85 | let screen = rows(for: Section(rawValue: indexPath.section)!)[indexPath.row]
86 | navigationController?.pushViewController(screen.viewControllerFactory(), animated: true)
87 | tableView.deselectRow(at: indexPath, animated: true)
88 | }
89 |
90 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
91 | switch Section(rawValue: section)! {
92 | case .integrationDemos:
93 | return "Sample Animations"
94 | case .featureDemos:
95 | return "Feature Explorations"
96 | case .debuggingTools:
97 | return "Debugging Tools"
98 | }
99 | }
100 |
101 | // MARK: - Private Methods
102 |
103 | private func rows(for section: Section) -> [RowModel] {
104 | switch section {
105 | case .integrationDemos:
106 | return demoScreens
107 | case .featureDemos:
108 | return featureScreens
109 | case .debuggingTools:
110 | return debuggingScreens
111 | }
112 | }
113 |
114 | // MARK: - Private Types
115 |
116 | private enum Section: Int, CaseIterable {
117 | case integrationDemos
118 | case featureDemos
119 | case debuggingTools
120 | }
121 |
122 | }
123 |
124 |
--------------------------------------------------------------------------------
/Example/Unit Tests/SnapshotTestingAPNGImageTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import SnapshotTesting
18 | import Stagehand
19 | import StagehandTesting
20 | import XCTest
21 |
22 | final class SnapshotTestingAPNGImageTests: SnapshotTestCase {
23 |
24 | // MARK: - Tests
25 |
26 | func testSimpleAnimationSnapshot() {
27 | let view = AnimatableContainerView(frame: .init(x: 0, y: 0, width: 200, height: 40))
28 |
29 | var animation = Animation()
30 | animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
31 | animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
32 |
33 | assertSnapshot(matching: view, as: .image, named: nameForDevice(baseName: "start"))
34 |
35 | assertSnapshot(matching: animation, as: .animatedImage(on: view), named: nameForDevice())
36 |
37 | // This intentionally uses the same identifier as the snapshot from before the animation to ensure that the view
38 | // is restored to its original state after snapshotting.
39 | assertSnapshot(matching: view, as: .image, named: nameForDevice(baseName: "start"))
40 | }
41 |
42 | func testAnimationSnapshotWithRepeatingAnimation() {
43 | let view = AnimatableContainerView(frame: .init(x: 0, y: 0, width: 200, height: 40))
44 |
45 | var animation = Animation()
46 | animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
47 | animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
48 | animation.addExecution(
49 | onForward: { $0.animatableView.backgroundColor = .green },
50 | onReverse: { $0.animatableView.backgroundColor = .blue },
51 | at: 0.5
52 | )
53 | animation.implicitRepeatStyle = .infinitelyRepeating(autoreversing: true)
54 |
55 | assertSnapshot(matching: animation, as: .animatedImage(on: view), named: nameForDevice())
56 | }
57 |
58 | func testAnimationWithNonViewElementSnapshot() {
59 | let view = AnimatableContainerView(frame: .init(x: 0, y: 0, width: 200, height: 40))
60 |
61 | let element = AnimatableContainerView.Proxy(view: view)
62 |
63 | var animation = Animation()
64 | animation.addKeyframe(for: \.animatableViewTransform, at: 0, value: .identity)
65 | animation.addKeyframe(for: \.animatableViewTransform, at: 1, value: .init(translationX: 160, y: 0))
66 |
67 | assertSnapshot(matching: view, as: .image, named: nameForDevice(baseName: "start"))
68 |
69 | assertSnapshot(matching: animation, as: .animatedImage(on: element, using: view), named: nameForDevice())
70 |
71 | // This intentionally uses the same identifier as the snapshot from before the animation to ensure that the view
72 | // is restored to its original state after snapshotting.
73 | assertSnapshot(matching: view, as: .image, named: nameForDevice(baseName: "start"))
74 | }
75 |
76 | func testAnimationGroupSnapshot() {
77 | let view = AnimatableContainerView(frame: .init(x: 0, y: 0, width: 200, height: 40))
78 |
79 | var animation = Animation()
80 | animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
81 | animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
82 |
83 | var animationGroup = AnimationGroup()
84 | animationGroup.addAnimation(animation, for: view, startingAt: 0, relativeDuration: 1)
85 |
86 | assertSnapshot(matching: view, as: .image, named: nameForDevice(baseName: "start"))
87 |
88 | assertSnapshot(matching: animationGroup, as: .animatedImage(using: view), named: nameForDevice())
89 |
90 | // This intentionally uses the same identifier as the snapshot from before the animation to ensure that the view
91 | // is restored to its original state after snapshotting.
92 | assertSnapshot(matching: view, as: .image, named: nameForDevice(baseName: "start"))
93 | }
94 |
95 | // MARK: - Private Methods
96 |
97 | private func nameForDevice(baseName: String? = nil) -> String {
98 | let size = UIScreen.main.bounds.size
99 | let scale = UIScreen.main.scale
100 | let version = UIDevice.current.systemVersion
101 | let deviceName = "\(Int(size.width))x\(Int(size.height))-\(version)-\(Int(scale))x"
102 |
103 | return [baseName, deviceName]
104 | .compactMap { $0 }
105 | .joined(separator: "-")
106 | }
107 |
108 | }
109 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Stagehand
2 |
3 | [](https://github.com/cashapp/stagehand/actions?query=workflow%3ACI+branch%3Amaster)
4 | [](https://cocoapods.org/pods/Stagehand)
5 | [](https://cocoapods.org/pods/Stagehand)
6 | [](https://cocoapods.org/pods/Stagehand)
7 |
8 | Stagehand provides a modern, type-safe API for building animations on iOS. Stagehand is designed around a set of core ideas:
9 |
10 | * **Composition of Structures** - Stagehand makes it easy to build complex, multi-part animations that are built from small, reusable pieces that are easier to reason about.
11 | * **Separation of Construction and Execution** - Stagehand provides separate mechanisms for the construction and execution, which increases the flexibility of animations and makes concepts like queuing a series of animations work straight out of the box.
12 | * **Compile-Time Safety** - Stagehand uses modern Swift features to provide a compile-time safe API for defining animations.
13 | * **Testability** - Stagehand builds on the concept of snapshot testing to introduce a visual testing paradigm for animations.
14 |
15 | ## Installation
16 |
17 | ### CocoaPods
18 |
19 | To install Stagehand via [CocoaPods](https://cocoapods.org), simply add the following line to your `Podfile`:
20 |
21 | ```ruby
22 | pod 'Stagehand'
23 | ```
24 |
25 | To install StagehandTesting, the animation snapshot testing utilities, add the following line to your test target definition in your `Podfile`:
26 |
27 | ```ruby
28 | pod 'StagehandTesting'
29 | ```
30 |
31 | By default, this will use Point-Free's [SnapshotTesting](https://github.com/pointfreeco/swift-snapshot-testing) to record snapshots and perform comparisons. To instead use Uber's [iOSSnapshotTestCase](https://github.com/uber/ios-snapshot-test-case) as the snapshotting engine, set your test target dependency to use the `iOSSnapshotTestCase` subspec.
32 |
33 | ```ruby
34 | pod 'StagehandTesting/iOSSnapshotTestCase'
35 | ```
36 |
37 | ### Swift Package Manager
38 |
39 | To install Stagehand via [Swift Package Manager](https://github.com/apple/swift-package-manager), add the following to your `Package.swift`:
40 |
41 | ```swift
42 | dependencies: [
43 | .package(url: "https://github.com/cashapp/stagehand", from: "4.0.0"),
44 | ],
45 | ```
46 |
47 | ## Getting Started with Stagehand
48 |
49 | 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.
50 |
51 | As an example, we can write an animation that highlights a view by fading its alpha to 0.8 and back:
52 |
53 | ```swift
54 | var highlightAnimation = Animation()
55 | highlightAnimation.addKeyframe(for: \.alpha, at: 0, value: 1)
56 | highlightAnimation.addKeyframe(for: \.alpha, at: 0.5, value: 0.8)
57 | highlightAnimation.addKeyframe(for: \.alpha, at: 1, value: 1)
58 | ```
59 |
60 | 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:
61 |
62 | ```swift
63 | var binaryAnimation = Animation()
64 | binaryAnimation.addChild(highlightAnimation, for: \.leftView, startingAt: 0, relativeDuration: 0.5)
65 | binaryAnimation.addChild(highlightAnimation, for: \.rightView, startingAt: 0.5, relativeDuration: 0.5)
66 | ```
67 |
68 | Once we've set up our view and we're ready to execute our animation, we can call the `perform` method to start animating:
69 |
70 | ```swift
71 | let view = BinaryView()
72 | // ...
73 |
74 | binaryAnimation.perform(on: view)
75 | ```
76 |
77 | ## Running the Demo App
78 |
79 | 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:
80 |
81 | ```bash
82 | bundle install
83 | bundle exec pod install
84 | open Stagehand.xcworkspace
85 | ```
86 |
87 | 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.
88 |
89 | ## Contributing
90 |
91 | 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.
92 |
93 | ## License
94 |
95 | ```
96 | Copyright 2020 Square, Inc.
97 |
98 | Licensed under the Apache License, Version 2.0 (the "License");
99 | you may not use this file except in compliance with the License.
100 | You may obtain a copy of the License at
101 |
102 | http://www.apache.org/licenses/LICENSE-2.0
103 |
104 | Unless required by applicable law or agreed to in writing, software
105 | distributed under the License is distributed on an "AS IS" BASIS,
106 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
107 | See the License for the specific language governing permissions and
108 | limitations under the License.
109 | ```
110 |
--------------------------------------------------------------------------------
/Example/Stagehand Tutorial.playground/Pages/Composing Animations.xcplaygroundpage/Contents.swift:
--------------------------------------------------------------------------------
1 | //: [Previous](@previous)
2 |
3 | import PlaygroundSupport
4 | import Stagehand
5 |
6 | /*:
7 |
8 | # Composing Animations
9 |
10 | One of the most powerful concepts that Stagehand introduces is the ability to compose animations. Small animations
11 | (such as those that affect only a few properties of a single view) are easy to reason about as a whole, but complex
12 | multi-part animations can be much more difficult.
13 |
14 | Stagehand allows for building a hierarchy of smaller animations that can be easily reasoned about, and composing them
15 | into a large animation that is executed as a single unit. This hierarchy is built up by adding child animations.
16 |
17 | For example, say we have a view with two subviews, each of which has a series of animations going on.
18 |
19 | */
20 |
21 | func makeFlatAnimation() -> Animation {
22 | var animation = Animation()
23 |
24 | animation.addKeyframe(for: \.topView.transform, at: 0, value: .identity)
25 | animation.addKeyframe(for: \.topView.transform, at: 1, value: .init(translationX: 200, y: 0))
26 |
27 | animation.addKeyframe(for: \.topView.backgroundColor, at: 0, value: .red)
28 | animation.addKeyframe(for: \.topView.backgroundColor, at: 0.25, value: UIColor.red.withAlphaComponent(0.8))
29 | animation.addKeyframe(for: \.topView.backgroundColor, at: 0.5, value: .red)
30 | animation.addKeyframe(for: \.topView.backgroundColor, at: 0.75, value: UIColor.red.withAlphaComponent(0.8))
31 | animation.addKeyframe(for: \.topView.backgroundColor, at: 1, value: .red)
32 |
33 | animation.addKeyframe(for: \.bottomView.transform, at: 0, value: .identity)
34 | animation.addKeyframe(for: \.bottomView.transform, at: 1, value: .init(translationX: 200, y: 0))
35 |
36 | animation.addKeyframe(for: \.bottomView.backgroundColor, at: 0, value: .yellow)
37 | animation.addKeyframe(for: \.bottomView.backgroundColor, at: 0.25, value: UIColor.yellow.withAlphaComponent(0.8))
38 | animation.addKeyframe(for: \.bottomView.backgroundColor, at: 0.5, value: .yellow)
39 | animation.addKeyframe(for: \.bottomView.backgroundColor, at: 0.75, value: UIColor.yellow.withAlphaComponent(0.8))
40 | animation.addKeyframe(for: \.bottomView.backgroundColor, at: 1, value: .yellow)
41 |
42 | animation.implicitDuration = 3
43 |
44 | return animation
45 | }
46 |
47 | let raceCarView = RaceCarView(frame: .init(x: 0, y: 0, width: 300, height: 200))
48 | PlaygroundPage.current.liveView = raceCarView
49 |
50 | let flatInstance = makeFlatAnimation().perform(on: raceCarView)
51 |
52 | /*:
53 |
54 | Run the playground up to this point to see our animation in action.
55 |
56 | */
57 |
58 | flatInstance.cancel()
59 |
60 | /*:
61 |
62 | Now let's write the same animation, except using the `addChild(_:)` method to compose our animation for separate
63 | animations for each subview.
64 |
65 | First, we'll make the animation for a single subview. In order to accomodate the differences in background color, we'll
66 | use relative keyframes. Using concepts like relative keyframes helps to make our animations more reusable in general.
67 |
68 | */
69 |
70 | func makeCarAnimation() -> Animation {
71 | var animation = Animation()
72 |
73 | animation.addKeyframe(for: \.transform, at: 0, value: .identity)
74 | animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: 200, y: 0))
75 |
76 | animation.addKeyframe(for: \.backgroundColor, at: 0, relativeValue: { $0 })
77 | animation.addKeyframe(for: \.backgroundColor, at: 0.25, relativeValue: { $0?.withAlphaComponent(0.8) })
78 | animation.addKeyframe(for: \.backgroundColor, at: 0.5, relativeValue: { $0 })
79 | animation.addKeyframe(for: \.backgroundColor, at: 0.75, relativeValue: { $0?.withAlphaComponent(0.8) })
80 | animation.addKeyframe(for: \.backgroundColor, at: 1, relativeValue: { $0 })
81 |
82 | return animation
83 | }
84 |
85 | /*:
86 |
87 | Now that we have the animation for each subview, we can compose them into the final animation.
88 |
89 | */
90 |
91 | func makeHierarchicalAnimation() -> Animation {
92 | var animation = Animation()
93 |
94 | animation.addChild(makeCarAnimation(), for: \.topView, startingAt: 0, relativeDuration: 1)
95 | animation.addChild(makeCarAnimation(), for: \.bottomView, startingAt: 0, relativeDuration: 1)
96 |
97 | animation.implicitDuration = 3
98 |
99 | return animation
100 | }
101 |
102 | let hierarchicalInstance = makeHierarchicalAnimation().perform(on: raceCarView)
103 |
104 | /*:
105 |
106 | Run the playground up to this point to see our new animation in action. It should look exactly the same as the previous
107 | (non-hierarchical) one.
108 |
109 | What if we want to have the top view win the race to the right? We can simply modify the `relativeDuration` of the
110 | first child animation to be shorter than 1, and all of the keyframes in that animation will be adjusted. No need to
111 | manual change each keyframe.
112 |
113 | We can also mix child animations with other content (like keyframes and execution blocks) in the parent animation.
114 | Any values defined in the parent will override values defined in child animations.
115 |
116 | By composing animation together, we can build complex, multi-part animations that are made of small reusable components
117 | that are easy to reason about.
118 |
119 | */
120 |
121 | //: [Next](@next)
122 |
--------------------------------------------------------------------------------
/Example/Unit Tests/SnapshotTestingFrameImageTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2020 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import SnapshotTesting
18 | import Stagehand
19 | import StagehandTesting
20 | import XCTest
21 |
22 | final class SnapshotTestingFrameImageTests: SnapshotTestCase {
23 |
24 | // MARK: - Tests
25 |
26 | func testSimpleAnimationSnapshot() {
27 | let view = AnimatableContainerView(frame: .init(x: 0, y: 0, width: 200, height: 40))
28 |
29 | var animation = Animation()
30 | animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
31 | animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
32 |
33 | assertSnapshot(
34 | matching: animation,
35 | as: .frameImage(on: view, at: 0.0),
36 | named: nameForDevice(baseName: "start")
37 | )
38 | assertSnapshot(
39 | matching: animation,
40 | as: .frameImage(on: view, at: 0.5),
41 | named: nameForDevice(baseName: "middle")
42 | )
43 | assertSnapshot(
44 | matching: animation,
45 | as: .frameImage(on: view, at: 1.0),
46 | named: nameForDevice(baseName: "end")
47 | )
48 |
49 | // This intentionally uses the same identifier as the animation at 0 to ensure that the view is restored to its
50 | // original state after snapshotting.
51 | assertSnapshot(matching: view, as: .image, named: nameForDevice(baseName: "start"))
52 | }
53 |
54 | func testAnimationWithNonViewElementSnapshot() {
55 | let view = AnimatableContainerView(frame: .init(x: 0, y: 0, width: 200, height: 40))
56 |
57 | let element = AnimatableContainerView.Proxy(view: view)
58 |
59 | var animation = Animation()
60 | animation.addKeyframe(for: \.animatableViewTransform, at: 0, value: .identity)
61 | animation.addKeyframe(for: \.animatableViewTransform, at: 1, value: .init(translationX: 160, y: 0))
62 |
63 | assertSnapshot(
64 | matching: animation,
65 | as: .frameImage(on: element, using: view, at: 0.0),
66 | named: nameForDevice(baseName: "start")
67 | )
68 | assertSnapshot(
69 | matching: animation,
70 | as: .frameImage(on: element, using: view, at: 0.5),
71 | named: nameForDevice(baseName: "middle")
72 | )
73 | assertSnapshot(
74 | matching: animation,
75 | as: .frameImage(on: element, using: view, at: 1.0),
76 | named: nameForDevice(baseName: "end")
77 | )
78 |
79 | // This intentionally uses the same identifier as the animation at 0 to ensure that the view is restored to its
80 | // original state after snapshotting.
81 | assertSnapshot(matching: view, as: .image, named: nameForDevice(baseName: "start"))
82 | }
83 |
84 | func testAnimationGroupSnapshot() {
85 | let view = AnimatableContainerView(frame: .init(x: 0, y: 0, width: 200, height: 40))
86 |
87 | var animation = Animation()
88 | animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
89 | animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
90 |
91 | var animationGroup = AnimationGroup()
92 | animationGroup.addAnimation(animation, for: view, startingAt: 0, relativeDuration: 1)
93 |
94 | assertSnapshot(
95 | matching: animationGroup,
96 | as: .frameImage(using: view, at: 0.0),
97 | named: nameForDevice(baseName: "start")
98 | )
99 | assertSnapshot(
100 | matching: animationGroup,
101 | as: .frameImage(using: view, at: 0.5),
102 | named: nameForDevice(baseName: "middle")
103 | )
104 | assertSnapshot(
105 | matching: animationGroup,
106 | as: .frameImage(using: view, at: 1.0),
107 | named: nameForDevice(baseName: "end")
108 | )
109 |
110 | // This intentionally uses the same identifier as the animation at 0 to ensure that the view is restored to its
111 | // original state after snapshotting.
112 | assertSnapshot(matching: view, as: .image, named: nameForDevice(baseName: "start"))
113 | }
114 |
115 | // MARK: - Private Methods
116 |
117 | private func nameForDevice(baseName: String? = nil) -> String {
118 | let size = UIScreen.main.bounds.size
119 | let scale = UIScreen.main.scale
120 | let version = UIDevice.current.systemVersion
121 | let deviceName = "\(Int(size.width))x\(Int(size.height))-\(version)-\(Int(scale))x"
122 |
123 | return [baseName, deviceName]
124 | .compactMap { $0 }
125 | .joined(separator: "-")
126 | }
127 |
128 | }
129 |
--------------------------------------------------------------------------------
/Sources/Stagehand/AnimationQueue.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2019 Square Inc.
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | import Foundation
18 |
19 | /// An `AnimationQueue` is a container for a set of animations that should be executed in sequence.
20 | public final class AnimationQueue {
21 |
22 | // MARK: - Life Cycle
23 |
24 | public init(element: ElementType) {
25 | self.element = element
26 | }
27 |
28 | // MARK: - Public Properties
29 |
30 | public var hasInProgressAnimation: Bool {
31 | guard let currentAnimation = queue.first else {
32 | return false
33 | }
34 |
35 | switch currentAnimation.instance.status {
36 | case .pending, .animating:
37 | return true
38 | case .complete, .canceled:
39 | return false
40 | }
41 | }
42 |
43 | // MARK: - Private Properties
44 |
45 | private let element: ElementType
46 |
47 | private var queue: [(instance: AnimationInstance, driver: DisplayLinkDriver)] = []
48 |
49 | // MARK: - Public Methods
50 |
51 | /// Adds the animation to the queue.
52 | ///
53 | /// If the queue was previously empty, the animation will begin immediately. If the queue was previously not empty,
54 | /// the animation will begin when the last animation in the queue has completed.
55 | ///
56 | /// The duration for each cycle of the animation will be determined in order of preference by:
57 | /// 1. An explicit duration, if provided via the `duration` parameter
58 | /// 2. The animation's implicit duration, as specified by the animation's `implicitDuration` property
59 | ///
60 | /// The repeat style for the animation will be determined in order of preference by:
61 | /// 1. An explicit repeat style, if provided via the `repeatStyle` parameter
62 | /// 2. The animation's implicit repeat style, as specified by the animation's `implicitRepeatStyle` property
63 | ///
64 | /// - parameter animation: The animation to add to the queue.
65 | /// - parameter duration: The duration to use for each cycle of the animation.
66 | /// - parameter repeatStyle: The repeat style to use for the animation.
67 | /// - returns: An animation instance that can be used to check the status of or cancel the animation.
68 | @discardableResult
69 | public func enqueue(
70 | animation: Animation,
71 | duration: TimeInterval? = nil,
72 | repeatStyle: AnimationRepeatStyle? = nil
73 | ) -> AnimationInstance {
74 | let driver = DisplayLinkDriver(
75 | delay: 0,
76 | duration: duration ?? animation.implicitDuration,
77 | repeatStyle: repeatStyle ?? animation.implicitRepeatStyle,
78 | completion: nil
79 | )
80 |
81 | let instance = AnimationInstance(
82 | animation: animation,
83 | element: element,
84 | driver: driver
85 | )
86 |
87 | queue.append((instance, driver))
88 |
89 | advanceToNextAnimationIfReady()
90 |
91 | return instance
92 | }
93 |
94 | /// Cancels all pending animations currently in the queue.
95 | public func cancelPendingAnimations() {
96 | queue.forEach { (instance, _) in
97 | if case .pending = instance.status {
98 | instance.cancel()
99 | }
100 | }
101 |
102 | purgeCompletedAndCanceledAnimations()
103 | }
104 |
105 | // MARK: - Private Methods
106 |
107 | private func advanceToNextAnimationIfReady() {
108 | guard let currentAnimation = queue.first else {
109 | return
110 | }
111 |
112 | switch currentAnimation.instance.status {
113 | case .pending:
114 | // The current animation hasn't started yet. It will be started below.
115 | break
116 |
117 | case .animating:
118 | // The current animation isn't complete yet.
119 | return
120 |
121 | case .complete, .canceled:
122 | // The current animation is complete. It will be purged below, then the next animation (if one is enqueued)
123 | // wil be started.
124 | break
125 | }
126 |
127 | purgeCompletedAndCanceledAnimations()
128 |
129 | guard let nextAnimation = queue.first else {
130 | // We've emptied the queue, nothing to do now.
131 | return
132 | }
133 |
134 | nextAnimation.driver.addCompletion { [weak self] _ in
135 | self?.advanceToNextAnimationIfReady()
136 | }
137 |
138 | nextAnimation.driver.start()
139 | }
140 |
141 | private func purgeCompletedAndCanceledAnimations() {
142 | queue.removeAll { (instance, _) -> Bool in
143 | switch instance.status {
144 | case .complete, .canceled:
145 | return true
146 | case .pending, .animating:
147 | return false
148 | }
149 | }
150 | }
151 |
152 | }
153 |
--------------------------------------------------------------------------------