├── 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 | [![CI Status](https://img.shields.io/github/actions/workflow/status/cashapp/stagehand/ci.yml?branch=master)](https://github.com/cashapp/stagehand/actions?query=workflow%3ACI+branch%3Amaster) 4 | [![Version](https://img.shields.io/cocoapods/v/Stagehand.svg?style=flat)](https://cocoapods.org/pods/Stagehand) 5 | [![License](https://img.shields.io/cocoapods/l/Stagehand.svg?style=flat)](https://cocoapods.org/pods/Stagehand) 6 | [![Platform](https://img.shields.io/cocoapods/p/Stagehand.svg?style=flat)](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 | --------------------------------------------------------------------------------