├── .github └── workflows │ └── build.yml ├── .gitignore ├── .spi.yml ├── Assets ├── AnimationPlanner.png └── sample-app.gif ├── LICENSE ├── Package.swift ├── Package@5.1.swift ├── README.md ├── Sample App ├── AnimationPlanner-Sample.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ └── contents.xcworkspacedata └── AnimationPlanner-Sample │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ ├── SceneDelegate.swift │ └── ViewController.swift ├── Sources └── AnimationPlanner │ ├── AnimationPlanner.swift │ ├── Animations │ ├── Animate.swift │ ├── AnimateDelayed.swift │ ├── AnimateSpring.swift │ ├── AnimationBuilder.swift │ ├── Extra.swift │ ├── Group.swift │ ├── Loop.swift │ ├── MapSequence.swift │ ├── Sequence.swift │ └── Wait.swift │ ├── Documentation.docc │ ├── AnimationPlanner.md │ └── creating-basic-animation-sequence.md │ ├── Extensions │ └── CAMediaTimingFunction.swift │ ├── Protocols │ ├── Animatable.swift │ ├── AnimationContainer.swift │ ├── AnimationConvertible.swift │ ├── AnimationModifiers.swift │ └── PerformsAnimations.swift │ └── RunningSequence.swift └── Tests └── AnimationPlannerTests ├── AnimationPlannerTests.swift ├── BaselineTests.swift ├── BuilderTests.swift ├── ComplexAnimationTests.swift ├── GroupTests.swift ├── SequenceTests.swift └── StoppingTests.swift /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | on: [push] 3 | 4 | jobs: 5 | build: 6 | runs-on: macos-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - name: Build 10 | run: xcodebuild build -scheme 'AnimationPlanner' -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14 Pro' 11 | - name: Run tests 12 | run: xcodebuild test -scheme 'AnimationPlanner' -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14 Pro' 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platform: ios 5 | documentation_targets: [AnimationPlanner] 6 | -------------------------------------------------------------------------------- /Assets/AnimationPlanner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PimCoumans/AnimationPlanner/8b980ea94df6839d48ad02a59f92b3e9f1ed9d4a/Assets/AnimationPlanner.png -------------------------------------------------------------------------------- /Assets/sample-app.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PimCoumans/AnimationPlanner/8b980ea94df6839d48ad02a59f92b3e9f1ed9d4a/Assets/sample-app.gif -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "AnimationPlanner", 7 | platforms: [ 8 | .iOS(.v11) 9 | ], 10 | products: [ 11 | .library( 12 | name: "AnimationPlanner", 13 | targets: ["AnimationPlanner"]), 14 | ], 15 | dependencies: [], 16 | targets: [ 17 | .target( 18 | name: "AnimationPlanner", 19 | dependencies: []), 20 | .testTarget( 21 | name: "AnimationPlannerTests", 22 | dependencies: ["AnimationPlanner"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /Package@5.1.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "AnimationPlanner", 7 | platforms: [ 8 | .iOS(.v11) 9 | ], 10 | products: [ 11 | .library( 12 | name: "AnimationPlanner", 13 | targets: ["AnimationPlanner"]), 14 | ], 15 | dependencies: [], 16 | targets: [ 17 | .target( 18 | name: "AnimationPlanner", 19 | dependencies: []), 20 | .testTarget( 21 | name: "AnimationPlannerTests", 22 | dependencies: ["AnimationPlanner"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FPimCoumans%2FAnimationPlanner%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/PimCoumans/AnimationPlanner) 2 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FPimCoumans%2FAnimationPlanner%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/PimCoumans/AnimationPlanner) 3 | 4 | ![Animation Planner logo](Assets/AnimationPlanner.png) 5 | 6 | # AnimationPlanner 7 | 8 | ⛓ Chain multiple `UIView` animations without endless closure nesting. Create your animation sequence all on the same indentation level using a clear, concise syntax. 9 | 10 | 🤹 Used for all exuberant animations in [OK Video 📲](https://okvideo.app/download) 11 | 12 | 📖 Check out the [documentation](https://swiftpackageindex.com/PimCoumans/AnimationPlanner/main/documentation/animationplanner) to get up to speed, or read on to see a little example. 13 | 14 | 15 | ## How do I plan my animations? 16 | 17 | 📦 Add `AnimationPlanner` to your project (using Swift Package manager) and start typing `AnimationPlanner.plan` to embark on your animation journey. Like what‘s happening in the code below. 18 | 19 | ```swift 20 | AnimationPlanner.plan { 21 | Animate(duration: 0.32, timingFunction: .quintOut) { 22 | view.alpha = 1 23 | view.center.y = self.view.bounds.midY 24 | } 25 | Wait(0.2) 26 | Animate(duration: 0.32) { 27 | view.transform = CGAffineTransform(scaleX: 2, y: 2) 28 | view.layer.cornerRadius = 40 29 | view.backgroundColor = .systemRed 30 | }.timingFunction(.quintOut) 31 | Wait(0.2) 32 | AnimateSpring(duration: 0.25, dampingRatio: 0.52) { 33 | view.backgroundColor = .systemBlue 34 | view.layer.cornerRadius = 0 35 | view.transform = .identity 36 | } 37 | Wait(0.58) 38 | Animate(duration: 0.2) { 39 | view.alpha = 0 40 | view.transform = .identity 41 | view.frame.origin.y = self.view.bounds.maxY 42 | }.timingFunction(.circIn) 43 | }.onComplete { finished in 44 | view.removeFromSuperview() 45 | } 46 | ``` 47 | 48 | The above code results in the following animation sequence. For more examples see the [Sample App](Sample%20App/AnimationPlanner-Sample/ViewController.swift) available when cloning the repo. 49 |

50 | 51 |

52 | 53 | _**Note:** The example uses [custom extension methods](Sources/AnimationPlanner/Extensions/CAMediaTimingFunction.swift) on `CAMediaTimingFunction`, included with the framework_ 54 | 55 | ## Installation 56 | 57 | ### 🛠 Adding AnimationPlanner as a package dependency 58 | 59 | 1. Go to `File` -> `Add Packages` 60 | 3. Paste `https://github.com/PimCoumans/AnimationPlanner` in the search bar and click on "Add Package" 61 | 4. Select the target(s) in which you want to use AnimationPlanner 62 | 63 | ### 📦 Swift Package Manager 64 | 65 | Manually add AnimationPlanner as a package dependency in `package.swift`, by updating your package definition with: 66 | 67 | ```swift 68 | dependencies: [ 69 | .package(name: "AnimationPlanner", url: "https://github.com/PimCoumans/AnimationPlanner.git", .branch("main")) 70 | ], 71 | ``` 72 | 73 | And updating your target‘s dependencies property with `dependencies: ["AnimationPlanner"]` 74 | 75 | ## 🔮 Future plans 76 | 77 | While this API removes a lot of unwanted nesting in completion closures when using traditional `UIView.animate...` calls, a project is never finished and for future versions I have the following plans: 78 | - Remove usage of inaccurate `DispatchQueue.main.asyncAfter`, currently used to add delays for non-`UIView` animations or bridging gaps between steps. 79 | - Maybe even allow this package to play more nicely with SwiftUI? No idea what that would look like though, any ideas? 80 | 81 | Got any feedback or suggestions? Please let me know! ✌🏻 82 | 83 | → [twitter.com/pimcoumans](https://twitter.com/pimcoumans) 84 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 3E5C6DA52848B09000720FE8 /* AnimationPlanner in Frameworks */ = {isa = PBXBuildFile; productRef = 3E5C6DA42848B09000720FE8 /* AnimationPlanner */; }; 11 | 3EF52D5D2848AEC7000F8222 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF52D5C2848AEC7000F8222 /* AppDelegate.swift */; }; 12 | 3EF52D5F2848AEC7000F8222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF52D5E2848AEC7000F8222 /* SceneDelegate.swift */; }; 13 | 3EF52D612848AEC7000F8222 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF52D602848AEC7000F8222 /* ViewController.swift */; }; 14 | 3EF52D642848AEC7000F8222 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3EF52D622848AEC7000F8222 /* Main.storyboard */; }; 15 | 3EF52D662848AEC8000F8222 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EF52D652848AEC8000F8222 /* Assets.xcassets */; }; 16 | 3EF52D692848AEC8000F8222 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3EF52D672848AEC8000F8222 /* LaunchScreen.storyboard */; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXFileReference section */ 20 | 3EF52D592848AEC7000F8222 /* AnimationPlanner-Sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "AnimationPlanner-Sample.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | 3EF52D5C2848AEC7000F8222 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 22 | 3EF52D5E2848AEC7000F8222 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 23 | 3EF52D602848AEC7000F8222 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 24 | 3EF52D632848AEC7000F8222 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 25 | 3EF52D652848AEC8000F8222 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 26 | 3EF52D682848AEC8000F8222 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 27 | 3EF52D6A2848AEC8000F8222 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 28 | 3EF52D772848B070000F8222 /* AnimationPlanner */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AnimationPlanner; path = ..; sourceTree = ""; }; 29 | /* End PBXFileReference section */ 30 | 31 | /* Begin PBXFrameworksBuildPhase section */ 32 | 3EF52D562848AEC7000F8222 /* Frameworks */ = { 33 | isa = PBXFrameworksBuildPhase; 34 | buildActionMask = 2147483647; 35 | files = ( 36 | 3E5C6DA52848B09000720FE8 /* AnimationPlanner in Frameworks */, 37 | ); 38 | runOnlyForDeploymentPostprocessing = 0; 39 | }; 40 | /* End PBXFrameworksBuildPhase section */ 41 | 42 | /* Begin PBXGroup section */ 43 | 3E5C6DA32848B09000720FE8 /* Frameworks */ = { 44 | isa = PBXGroup; 45 | children = ( 46 | ); 47 | name = Frameworks; 48 | sourceTree = ""; 49 | }; 50 | 3EF52D502848AEC7000F8222 = { 51 | isa = PBXGroup; 52 | children = ( 53 | 3EF52D762848B070000F8222 /* Packages */, 54 | 3EF52D5B2848AEC7000F8222 /* AnimationPlanner-Sample */, 55 | 3EF52D5A2848AEC7000F8222 /* Products */, 56 | 3E5C6DA32848B09000720FE8 /* Frameworks */, 57 | ); 58 | sourceTree = ""; 59 | }; 60 | 3EF52D5A2848AEC7000F8222 /* Products */ = { 61 | isa = PBXGroup; 62 | children = ( 63 | 3EF52D592848AEC7000F8222 /* AnimationPlanner-Sample.app */, 64 | ); 65 | name = Products; 66 | sourceTree = ""; 67 | }; 68 | 3EF52D5B2848AEC7000F8222 /* AnimationPlanner-Sample */ = { 69 | isa = PBXGroup; 70 | children = ( 71 | 3EF52D5C2848AEC7000F8222 /* AppDelegate.swift */, 72 | 3EF52D5E2848AEC7000F8222 /* SceneDelegate.swift */, 73 | 3EF52D602848AEC7000F8222 /* ViewController.swift */, 74 | 3EF52D622848AEC7000F8222 /* Main.storyboard */, 75 | 3EF52D652848AEC8000F8222 /* Assets.xcassets */, 76 | 3EF52D672848AEC8000F8222 /* LaunchScreen.storyboard */, 77 | 3EF52D6A2848AEC8000F8222 /* Info.plist */, 78 | ); 79 | path = "AnimationPlanner-Sample"; 80 | sourceTree = ""; 81 | }; 82 | 3EF52D762848B070000F8222 /* Packages */ = { 83 | isa = PBXGroup; 84 | children = ( 85 | 3EF52D772848B070000F8222 /* AnimationPlanner */, 86 | ); 87 | name = Packages; 88 | sourceTree = ""; 89 | }; 90 | /* End PBXGroup section */ 91 | 92 | /* Begin PBXNativeTarget section */ 93 | 3EF52D582848AEC7000F8222 /* AnimationPlanner-Sample */ = { 94 | isa = PBXNativeTarget; 95 | buildConfigurationList = 3EF52D6D2848AEC8000F8222 /* Build configuration list for PBXNativeTarget "AnimationPlanner-Sample" */; 96 | buildPhases = ( 97 | 3EF52D552848AEC7000F8222 /* Sources */, 98 | 3EF52D562848AEC7000F8222 /* Frameworks */, 99 | 3EF52D572848AEC7000F8222 /* Resources */, 100 | ); 101 | buildRules = ( 102 | ); 103 | dependencies = ( 104 | ); 105 | name = "AnimationPlanner-Sample"; 106 | packageProductDependencies = ( 107 | 3E5C6DA42848B09000720FE8 /* AnimationPlanner */, 108 | ); 109 | productName = "AnimationPlanner-Sample"; 110 | productReference = 3EF52D592848AEC7000F8222 /* AnimationPlanner-Sample.app */; 111 | productType = "com.apple.product-type.application"; 112 | }; 113 | /* End PBXNativeTarget section */ 114 | 115 | /* Begin PBXProject section */ 116 | 3EF52D512848AEC7000F8222 /* Project object */ = { 117 | isa = PBXProject; 118 | attributes = { 119 | BuildIndependentTargetsInParallel = 1; 120 | LastSwiftUpdateCheck = 1340; 121 | LastUpgradeCheck = 1520; 122 | TargetAttributes = { 123 | 3EF52D582848AEC7000F8222 = { 124 | CreatedOnToolsVersion = 13.4; 125 | }; 126 | }; 127 | }; 128 | buildConfigurationList = 3EF52D542848AEC7000F8222 /* Build configuration list for PBXProject "AnimationPlanner-Sample" */; 129 | compatibilityVersion = "Xcode 13.0"; 130 | developmentRegion = en; 131 | hasScannedForEncodings = 0; 132 | knownRegions = ( 133 | en, 134 | Base, 135 | ); 136 | mainGroup = 3EF52D502848AEC7000F8222; 137 | productRefGroup = 3EF52D5A2848AEC7000F8222 /* Products */; 138 | projectDirPath = ""; 139 | projectRoot = ""; 140 | targets = ( 141 | 3EF52D582848AEC7000F8222 /* AnimationPlanner-Sample */, 142 | ); 143 | }; 144 | /* End PBXProject section */ 145 | 146 | /* Begin PBXResourcesBuildPhase section */ 147 | 3EF52D572848AEC7000F8222 /* Resources */ = { 148 | isa = PBXResourcesBuildPhase; 149 | buildActionMask = 2147483647; 150 | files = ( 151 | 3EF52D692848AEC8000F8222 /* LaunchScreen.storyboard in Resources */, 152 | 3EF52D662848AEC8000F8222 /* Assets.xcassets in Resources */, 153 | 3EF52D642848AEC7000F8222 /* Main.storyboard in Resources */, 154 | ); 155 | runOnlyForDeploymentPostprocessing = 0; 156 | }; 157 | /* End PBXResourcesBuildPhase section */ 158 | 159 | /* Begin PBXSourcesBuildPhase section */ 160 | 3EF52D552848AEC7000F8222 /* Sources */ = { 161 | isa = PBXSourcesBuildPhase; 162 | buildActionMask = 2147483647; 163 | files = ( 164 | 3EF52D612848AEC7000F8222 /* ViewController.swift in Sources */, 165 | 3EF52D5D2848AEC7000F8222 /* AppDelegate.swift in Sources */, 166 | 3EF52D5F2848AEC7000F8222 /* SceneDelegate.swift in Sources */, 167 | ); 168 | runOnlyForDeploymentPostprocessing = 0; 169 | }; 170 | /* End PBXSourcesBuildPhase section */ 171 | 172 | /* Begin PBXVariantGroup section */ 173 | 3EF52D622848AEC7000F8222 /* Main.storyboard */ = { 174 | isa = PBXVariantGroup; 175 | children = ( 176 | 3EF52D632848AEC7000F8222 /* Base */, 177 | ); 178 | name = Main.storyboard; 179 | sourceTree = ""; 180 | }; 181 | 3EF52D672848AEC8000F8222 /* LaunchScreen.storyboard */ = { 182 | isa = PBXVariantGroup; 183 | children = ( 184 | 3EF52D682848AEC8000F8222 /* Base */, 185 | ); 186 | name = LaunchScreen.storyboard; 187 | sourceTree = ""; 188 | }; 189 | /* End PBXVariantGroup section */ 190 | 191 | /* Begin XCBuildConfiguration section */ 192 | 3EF52D6B2848AEC8000F8222 /* Debug */ = { 193 | isa = XCBuildConfiguration; 194 | buildSettings = { 195 | ALWAYS_SEARCH_USER_PATHS = NO; 196 | CLANG_ANALYZER_NONNULL = YES; 197 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 198 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 199 | CLANG_ENABLE_MODULES = YES; 200 | CLANG_ENABLE_OBJC_ARC = YES; 201 | CLANG_ENABLE_OBJC_WEAK = YES; 202 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 203 | CLANG_WARN_BOOL_CONVERSION = YES; 204 | CLANG_WARN_COMMA = YES; 205 | CLANG_WARN_CONSTANT_CONVERSION = YES; 206 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 207 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 208 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 209 | CLANG_WARN_EMPTY_BODY = YES; 210 | CLANG_WARN_ENUM_CONVERSION = YES; 211 | CLANG_WARN_INFINITE_RECURSION = YES; 212 | CLANG_WARN_INT_CONVERSION = YES; 213 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 214 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 215 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 216 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 217 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 218 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 219 | CLANG_WARN_STRICT_PROTOTYPES = YES; 220 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 221 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 222 | CLANG_WARN_UNREACHABLE_CODE = YES; 223 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 224 | COPY_PHASE_STRIP = NO; 225 | DEBUG_INFORMATION_FORMAT = dwarf; 226 | ENABLE_STRICT_OBJC_MSGSEND = YES; 227 | ENABLE_TESTABILITY = YES; 228 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 229 | GCC_C_LANGUAGE_STANDARD = gnu11; 230 | GCC_DYNAMIC_NO_PIC = NO; 231 | GCC_NO_COMMON_BLOCKS = YES; 232 | GCC_OPTIMIZATION_LEVEL = 0; 233 | GCC_PREPROCESSOR_DEFINITIONS = ( 234 | "DEBUG=1", 235 | "$(inherited)", 236 | ); 237 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 238 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 239 | GCC_WARN_UNDECLARED_SELECTOR = YES; 240 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 241 | GCC_WARN_UNUSED_FUNCTION = YES; 242 | GCC_WARN_UNUSED_VARIABLE = YES; 243 | IPHONEOS_DEPLOYMENT_TARGET = 15.5; 244 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 245 | MTL_FAST_MATH = YES; 246 | ONLY_ACTIVE_ARCH = YES; 247 | SDKROOT = iphoneos; 248 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 249 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 250 | }; 251 | name = Debug; 252 | }; 253 | 3EF52D6C2848AEC8000F8222 /* Release */ = { 254 | isa = XCBuildConfiguration; 255 | buildSettings = { 256 | ALWAYS_SEARCH_USER_PATHS = NO; 257 | CLANG_ANALYZER_NONNULL = YES; 258 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 259 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 260 | CLANG_ENABLE_MODULES = YES; 261 | CLANG_ENABLE_OBJC_ARC = YES; 262 | CLANG_ENABLE_OBJC_WEAK = YES; 263 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 264 | CLANG_WARN_BOOL_CONVERSION = YES; 265 | CLANG_WARN_COMMA = YES; 266 | CLANG_WARN_CONSTANT_CONVERSION = YES; 267 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 268 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 269 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 270 | CLANG_WARN_EMPTY_BODY = YES; 271 | CLANG_WARN_ENUM_CONVERSION = YES; 272 | CLANG_WARN_INFINITE_RECURSION = YES; 273 | CLANG_WARN_INT_CONVERSION = YES; 274 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 275 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 276 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 277 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 278 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 279 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 280 | CLANG_WARN_STRICT_PROTOTYPES = YES; 281 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 282 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 283 | CLANG_WARN_UNREACHABLE_CODE = YES; 284 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 285 | COPY_PHASE_STRIP = NO; 286 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 287 | ENABLE_NS_ASSERTIONS = NO; 288 | ENABLE_STRICT_OBJC_MSGSEND = YES; 289 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 290 | GCC_C_LANGUAGE_STANDARD = gnu11; 291 | GCC_NO_COMMON_BLOCKS = YES; 292 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 293 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 294 | GCC_WARN_UNDECLARED_SELECTOR = YES; 295 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 296 | GCC_WARN_UNUSED_FUNCTION = YES; 297 | GCC_WARN_UNUSED_VARIABLE = YES; 298 | IPHONEOS_DEPLOYMENT_TARGET = 15.5; 299 | MTL_ENABLE_DEBUG_INFO = NO; 300 | MTL_FAST_MATH = YES; 301 | SDKROOT = iphoneos; 302 | SWIFT_COMPILATION_MODE = wholemodule; 303 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 304 | VALIDATE_PRODUCT = YES; 305 | }; 306 | name = Release; 307 | }; 308 | 3EF52D6E2848AEC8000F8222 /* Debug */ = { 309 | isa = XCBuildConfiguration; 310 | buildSettings = { 311 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 312 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 313 | CODE_SIGN_STYLE = Automatic; 314 | CURRENT_PROJECT_VERSION = 1; 315 | DEVELOPMENT_TEAM = QB4G3AY8F8; 316 | GENERATE_INFOPLIST_FILE = YES; 317 | INFOPLIST_FILE = "AnimationPlanner-Sample/Info.plist"; 318 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 319 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 320 | INFOPLIST_KEY_UIMainStoryboardFile = Main; 321 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 322 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 323 | LD_RUNPATH_SEARCH_PATHS = ( 324 | "$(inherited)", 325 | "@executable_path/Frameworks", 326 | ); 327 | MARKETING_VERSION = 1.0; 328 | PRODUCT_BUNDLE_IDENTIFIER = "nl.pixelrock.AnimationPlanner-Sample"; 329 | PRODUCT_NAME = "$(TARGET_NAME)"; 330 | SWIFT_EMIT_LOC_STRINGS = YES; 331 | SWIFT_VERSION = 5.0; 332 | TARGETED_DEVICE_FAMILY = "1,2"; 333 | }; 334 | name = Debug; 335 | }; 336 | 3EF52D6F2848AEC8000F8222 /* Release */ = { 337 | isa = XCBuildConfiguration; 338 | buildSettings = { 339 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 340 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 341 | CODE_SIGN_STYLE = Automatic; 342 | CURRENT_PROJECT_VERSION = 1; 343 | DEVELOPMENT_TEAM = QB4G3AY8F8; 344 | GENERATE_INFOPLIST_FILE = YES; 345 | INFOPLIST_FILE = "AnimationPlanner-Sample/Info.plist"; 346 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 347 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 348 | INFOPLIST_KEY_UIMainStoryboardFile = Main; 349 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 350 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 351 | LD_RUNPATH_SEARCH_PATHS = ( 352 | "$(inherited)", 353 | "@executable_path/Frameworks", 354 | ); 355 | MARKETING_VERSION = 1.0; 356 | PRODUCT_BUNDLE_IDENTIFIER = "nl.pixelrock.AnimationPlanner-Sample"; 357 | PRODUCT_NAME = "$(TARGET_NAME)"; 358 | SWIFT_EMIT_LOC_STRINGS = YES; 359 | SWIFT_VERSION = 5.0; 360 | TARGETED_DEVICE_FAMILY = "1,2"; 361 | }; 362 | name = Release; 363 | }; 364 | /* End XCBuildConfiguration section */ 365 | 366 | /* Begin XCConfigurationList section */ 367 | 3EF52D542848AEC7000F8222 /* Build configuration list for PBXProject "AnimationPlanner-Sample" */ = { 368 | isa = XCConfigurationList; 369 | buildConfigurations = ( 370 | 3EF52D6B2848AEC8000F8222 /* Debug */, 371 | 3EF52D6C2848AEC8000F8222 /* Release */, 372 | ); 373 | defaultConfigurationIsVisible = 0; 374 | defaultConfigurationName = Release; 375 | }; 376 | 3EF52D6D2848AEC8000F8222 /* Build configuration list for PBXNativeTarget "AnimationPlanner-Sample" */ = { 377 | isa = XCConfigurationList; 378 | buildConfigurations = ( 379 | 3EF52D6E2848AEC8000F8222 /* Debug */, 380 | 3EF52D6F2848AEC8000F8222 /* Release */, 381 | ); 382 | defaultConfigurationIsVisible = 0; 383 | defaultConfigurationName = Release; 384 | }; 385 | /* End XCConfigurationList section */ 386 | 387 | /* Begin XCSwiftPackageProductDependency section */ 388 | 3E5C6DA42848B09000720FE8 /* AnimationPlanner */ = { 389 | isa = XCSwiftPackageProductDependency; 390 | productName = AnimationPlanner; 391 | }; 392 | /* End XCSwiftPackageProductDependency section */ 393 | }; 394 | rootObject = 3EF52D512848AEC7000F8222 /* Project object */; 395 | } 396 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // AnimationPlanner-Sample 4 | // 5 | // Created by Pim on 02/06/2022. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | 14 | 15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 16 | // Override point for customization after application launch. 17 | return true 18 | } 19 | 20 | // MARK: UISceneSession Lifecycle 21 | 22 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 23 | // Called when a new scene session is being created. 24 | // Use this method to select a configuration to create the new scene with. 25 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 26 | } 27 | 28 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 29 | // Called when the user discards a scene session. 30 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 31 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 32 | } 33 | 34 | 35 | } 36 | 37 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | UISceneStoryboardFile 19 | Main 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // AnimationPlanner-Sample 4 | // 5 | // Created by Pim on 02/06/2022. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | 15 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 16 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 17 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 18 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 19 | guard let _ = (scene as? UIWindowScene) else { return } 20 | } 21 | 22 | func sceneDidDisconnect(_ scene: UIScene) { 23 | // Called as the scene is being released by the system. 24 | // This occurs shortly after the scene enters the background, or when its session is discarded. 25 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 26 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 27 | } 28 | 29 | func sceneDidBecomeActive(_ scene: UIScene) { 30 | // Called when the scene has moved from an inactive state to an active state. 31 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 32 | } 33 | 34 | func sceneWillResignActive(_ scene: UIScene) { 35 | // Called when the scene will move from an active state to an inactive state. 36 | // This may occur due to temporary interruptions (ex. an incoming phone call). 37 | } 38 | 39 | func sceneWillEnterForeground(_ scene: UIScene) { 40 | // Called as the scene transitions from the background to the foreground. 41 | // Use this method to undo the changes made on entering the background. 42 | } 43 | 44 | func sceneDidEnterBackground(_ scene: UIScene) { 45 | // Called as the scene transitions from the foreground to the background. 46 | // Use this method to save data, release shared resources, and store enough scene-specific state information 47 | // to restore the scene back to its current state. 48 | } 49 | 50 | 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Sample App/AnimationPlanner-Sample/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // AnimationPlanner-Sample 4 | // 5 | // Created by Pim on 02/06/2022. 6 | // 7 | 8 | import UIKit 9 | import AnimationPlanner 10 | 11 | class ViewController: UIViewController { 12 | 13 | lazy var subview: UIView = { 14 | let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) 15 | view.layer.cornerCurve = .continuous 16 | return view 17 | }() 18 | 19 | lazy var stopButton = newStopButton() 20 | lazy var resetButton = newResetButton() 21 | 22 | // Sequence currently performing animations 23 | var runningSequence: RunningSequence? 24 | 25 | let testStopping: Bool = true // Set to true to display buttons to stop and reset animations 26 | 27 | let performComplexAnimation: Bool = false // Set to true to run a more complex animation 28 | 29 | func performAnimations() { 30 | resetButton.isEnabled = false 31 | if performComplexAnimation { 32 | runComplexBuilderAnimation() 33 | } else { 34 | runSimpleBuilderAnimation() 35 | } 36 | } 37 | 38 | override func viewDidAppear(_ animated: Bool) { 39 | super.viewDidAppear(animated) 40 | performAnimations() 41 | } 42 | 43 | override func viewDidLoad() { 44 | super.viewDidLoad() 45 | view.addSubview(subview) 46 | 47 | guard testStopping else { 48 | return 49 | } 50 | view.addSubview(stopButton) 51 | view.addSubview(resetButton) 52 | stopButton.translatesAutoresizingMaskIntoConstraints = false 53 | resetButton.translatesAutoresizingMaskIntoConstraints = false 54 | NSLayoutConstraint.activate([ 55 | stopButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), 56 | stopButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), 57 | stopButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor, constant: -8), 58 | resetButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor, constant: 8), 59 | resetButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), 60 | resetButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) 61 | ]) 62 | } 63 | } 64 | 65 | extension ViewController { 66 | 67 | func runSimpleBuilderAnimation() { 68 | let view = setInitialSubviewState() 69 | runningSequence = AnimationPlanner.plan { 70 | Wait(0.35) // A delay waits for the given amount of seconds to start the next step 71 | Animate(duration: 0.32, timingFunction: .quintOut) { 72 | view.alpha = 1 73 | view.center.y = self.view.bounds.midY 74 | } 75 | Wait(0.2) 76 | Animate(duration: 0.32) { 77 | view.transform = CGAffineTransform(scaleX: 2, y: 2) 78 | view.layer.cornerRadius = 40 79 | view.backgroundColor = .systemRed 80 | }.timingFunction(.quintOut) 81 | Wait(0.2) 82 | AnimateSpring(duration: 0.25, dampingRatio: 0.52) { 83 | view.backgroundColor = .systemBlue 84 | view.layer.cornerRadius = 0 85 | view.transform = .identity 86 | } 87 | Wait(0.58) 88 | Animate(duration: 0.2) { 89 | view.alpha = 0 90 | view.transform = .identity 91 | view.frame.origin.y = self.view.bounds.maxY 92 | }.timingFunction(.circIn) 93 | }.onComplete { finished in 94 | if finished { 95 | // Just to keep the flow going, let‘s run the animation again 96 | self.runSimpleBuilderAnimation() 97 | } 98 | } 99 | } 100 | 101 | 102 | func runComplexBuilderAnimation() { 103 | var sneakyCopy: UIView! // Don‘t worry, you‘ll see later 104 | 105 | runningSequence = AnimationPlanner.plan { 106 | let quarterHeight = view.bounds.height / 4 107 | let view = setInitialSubviewState() 108 | 109 | Wait(0.2) 110 | Animate(duration: 1) { 111 | view.alpha = 1 112 | view.center.y = quarterHeight 113 | }.timingFunction(.quartOut) 114 | Wait(0.2) 115 | Animate(duration: 0.35) { 116 | view.transform = view.transform.scaledBy(x: 0.9, y: 0.9) 117 | view.layer.cornerRadius = 40 118 | }.timingFunction(.backOut) 119 | Animate(duration: 1) { 120 | view.frame.origin.y += quarterHeight 121 | view.transform = .identity 122 | }.timingFunction(.cubicInOut) 123 | Wait(0.32) 124 | var initialCornerRadius: CGFloat = 0 125 | 126 | Extra { 127 | // Trick to get specific value at time of animation 128 | initialCornerRadius = view.layer.cornerRadius 129 | } 130 | 131 | let loopCount = 4 132 | 133 | // Adding multiple steps can be done with a for-in statement 134 | // or by adding `.mapSequence { }` or `.mapGroup { }` to any sequence 135 | for index in 1...loopCount { 136 | let offset = CGFloat(index) / CGFloat(loopCount) 137 | let reversed = 1 - offset 138 | Animate(duration: 0.32) { 139 | view.transform = CGAffineTransform( 140 | rotationAngle: .pi * offset 141 | ).scaledBy( 142 | x: 1 + offset / 2, 143 | y: 1 + offset / 2) 144 | view.layer.cornerRadius = initialCornerRadius * reversed 145 | }.spring(damping: 0.62) 146 | Wait(0.2) 147 | } 148 | 149 | Extra { 150 | // reset rotation 151 | view.transform = view.transform.rotated(by: .pi) 152 | } 153 | 154 | // Example of using a custom method (defined further down) for a specific animation 155 | addShakeSequence(shaking: view) 156 | Extra { 157 | // An ‘extra’ step performs non-animating setup logic 158 | // like adding another view to the mix 159 | sneakyCopy = view.sneakyCopy() 160 | } 161 | Animate(duration: 0.25) { 162 | sneakyCopy.isHidden = false 163 | sneakyCopy.transform = CGAffineTransform(translationX: 0, y: -view.frame.height - 20) 164 | sneakyCopy.backgroundColor = .systemYellow 165 | }.timingFunction(.backOut) 166 | Wait(0.35) 167 | Animate(duration: 1.2) { 168 | view.transform = .identity 169 | let offset = view.frame.origin.y + quarterHeight 170 | view.frame.origin = CGPoint(x: view.frame.minX - (view.frame.width / 2) - 10, y: offset) 171 | sneakyCopy.frame = view.frame.offsetBy(dx: view.frame.width + 20, dy: 0) 172 | view.backgroundColor = .systemPink 173 | }.timingFunction(.quartInOut) 174 | Wait(0.5) 175 | Group { 176 | // A group performs all of its animations at once, 177 | // finishing when the longest animation completes 178 | // Use a delay for a staggered effect 179 | AnimateDelayed(delay: 0.2, duration: 0.5) { 180 | sneakyCopy.transform = CGAffineTransform(translationX: 0, y: -50).concatenating(sneakyCopy.transform) 181 | }.timingFunction(.backOut) 182 | AnimateDelayed(delay: 0.1, duration: 0.2) { 183 | view.layer.borderColor = view.backgroundColor?.cgColor 184 | view.layer.borderWidth = 4 185 | sneakyCopy.layer.borderColor = sneakyCopy.backgroundColor?.cgColor 186 | sneakyCopy.layer.borderWidth = 4 187 | }.timingFunction(.cubicOut) 188 | Animate(duration: 1) { 189 | let viewColor = view.backgroundColor 190 | view.backgroundColor = sneakyCopy.backgroundColor 191 | sneakyCopy.backgroundColor = viewColor 192 | } 193 | } 194 | Wait(0.32) 195 | Animate(duration: 0.5) { 196 | view.alpha = 0 197 | sneakyCopy?.alpha = 0 198 | // you can use values set in previous animations 199 | // as the animations are created after the previous animation completes 200 | view.transform = view.transform.translatedBy(x: 0, y: quarterHeight) 201 | sneakyCopy?.transform = view.transform.translatedBy(x: 0, y: quarterHeight) 202 | } 203 | }.onComplete { finished in 204 | if finished { 205 | sneakyCopy.removeFromSuperview() 206 | self.runComplexBuilderAnimation() 207 | } 208 | } 209 | } 210 | } 211 | 212 | extension ViewController { 213 | 214 | func setInitialSubviewState() -> UIView { 215 | subview.alpha = 0 216 | subview.transform = .identity 217 | subview.frame.size = CGSize(width: 100, height: 100) 218 | subview.center.x = view.bounds.midX 219 | subview.frame.origin.y = view.bounds.minY 220 | subview.backgroundColor = .systemOrange 221 | subview.layer.cornerRadius = 16 222 | subview.layer.borderWidth = 0 223 | subview.layer.borderColor = nil 224 | return subview 225 | } 226 | 227 | /// Adds a custom shake animation sequence on the provided view 228 | /// - Parameter view: View to which the transform should be applied 229 | /// - Returns: Animations to be added to the sequence 230 | @SequenceBuilder 231 | func addShakeSequence(shaking view: UIView) -> [SequenceAnimatable] { 232 | var baseTransform: CGAffineTransform = .identity 233 | 234 | let count = 50 235 | let maxRadius: CGFloat = 4 236 | let values = (0.. UIButton { 254 | var configuration = UIButton.Configuration.filled() 255 | configuration.buttonSize = .large 256 | configuration.baseBackgroundColor = .systemRed 257 | configuration.cornerStyle = .large 258 | configuration.title = "Stop" 259 | return UIButton(configuration: configuration, primaryAction: UIAction { [unowned self] _ in 260 | runningSequence?.stopAnimations() 261 | resetButton.isEnabled = true 262 | }) 263 | } 264 | 265 | func newResetButton() -> UIButton { 266 | var configuration = UIButton.Configuration.filled() 267 | configuration.buttonSize = .large 268 | configuration.baseBackgroundColor = .systemGreen 269 | configuration.cornerStyle = .large 270 | configuration.title = "Reset" 271 | let button = UIButton(configuration: configuration, primaryAction: UIAction { [unowned self] _ in 272 | performAnimations() 273 | }) 274 | button.isEnabled = false 275 | return button 276 | } 277 | } 278 | 279 | extension UIView { 280 | func sneakyCopy() -> Self? { 281 | // 🫣🫣🫣 282 | do { 283 | let archiver = NSKeyedArchiver(requiringSecureCoding: false) 284 | archiver.encodeRootObject(self) 285 | let data = archiver.encodedData 286 | 287 | let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) 288 | unarchiver.requiresSecureCoding = false 289 | 290 | guard let view = unarchiver.decodeObject() as? Self else { 291 | return nil 292 | } 293 | view.isHidden = true 294 | superview?.insertSubview(view, belowSubview: self) 295 | return view 296 | } 297 | catch { 298 | print(error) 299 | return nil 300 | } 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/AnimationPlanner.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Chain multiple `UIView` animations with a clear declarative syntax, describing each step along the way. 4 | /// Start by typing `AnimationPlanner.plan` and provide all of your animations from the `animations` closure. 5 | /// 6 | /// Begin planning your animation by using either of the following static methods: 7 | /// - ``plan(animations:)`` start a sequence animation where all animations are performed in order. 8 | /// - ``group(animations:)`` start a group animation where all animations are performed simultaneously. 9 | /// 10 | /// - Tip: To get started, read and get up to speed on how to use AnimationPlanner, 11 | /// or go through the whole documentation on ``AnimationPlanner`` to get an overview of all the available functionalities. 12 | public struct AnimationPlanner { 13 | 14 | /// Start a new animation sequence where animations added will be performed in order, meaning a subsequent animation starts right after the previous finishes. 15 | /// 16 | /// ```swift 17 | /// AnimationPlanner.plan { 18 | /// Animate(duration: 0.25) { view.backgroundColor = .systemRed } 19 | /// Wait(0.5) 20 | /// Animate(duration: 0.5) { 21 | /// view.transform = CGAffineTransform(scaleX: 1.5, y: 1.5) 22 | /// }.spring(damping: 0.68) 23 | /// } 24 | /// ``` 25 | /// - Parameters: 26 | /// - animations: Add each animation using this closure. Animation added to a sequence should conform to ``GroupAnimatable``. 27 | /// - Returns: Instance of ``RunningSequence`` to keep track of and stop animations 28 | @discardableResult 29 | public static func plan( 30 | @SequenceBuilder animations builder: () -> [SequenceAnimatable] 31 | ) -> RunningSequence { 32 | RunningSequence(animations: builder()) 33 | .animate() 34 | } 35 | 36 | /// Start a new group animation where animations added will be performed simultaneously, meaning all animations run at the same time. 37 | /// 38 | /// ```swift 39 | /// AnimationPlanner.group { 40 | /// Animate(duration: 0.5) { 41 | /// view.frame.origin.y = 0 42 | /// }.delayed(0.15) 43 | /// Animate(duration: 0.3) { 44 | /// view.backgroundColor = .systemBlue 45 | /// }.delayed(0.2) 46 | /// } 47 | /// ``` 48 | /// 49 | /// - Parameters: 50 | /// - animations: Add each animation using this closure. Animation added to a group should conform to ``GroupAnimatable``. 51 | /// - Returns: Instance of ``RunningSequence`` to keep track of and stop animations 52 | @discardableResult 53 | public static func group( 54 | @GroupBuilder animations builder: () -> [GroupAnimatable] 55 | ) -> RunningSequence { 56 | plan { 57 | Group(animations: builder) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Animations/Animate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Performs an animation with the provided duration in seconds. Includes properties to set `UIView.AnimationOptions` and 4 | /// even a `CAMediaTimingFunction` to apply to the interpolation of the animated values changed in the ``changes`` closure. 5 | public struct Animate: Animation, SequenceAnimatable, GroupAnimatable { 6 | public let duration: TimeInterval 7 | 8 | public internal(set) var changes: () -> Void 9 | public internal(set) var options: UIView.AnimationOptions? 10 | public internal(set) var timingFunction: CAMediaTimingFunction? 11 | 12 | /// Class that holds stopped state 13 | internal let stopper: Stopper 14 | 15 | /// Creates a new animation, animating the properties updated in the ``changes`` closure 16 | /// 17 | /// Only the `duration` parameter is required, all other properties can be added or modified using ``AnimationModifiers``. 18 | /// 19 | /// - Tip: AnimationPlanner provides numerous animation curves through a `CAMediaTimingFunction` extension. 20 | /// Type a period for the `timingFunction` parameter to see what is readily available. Have you tried `.quintOut` yet? 21 | /// 22 | /// - Parameters: 23 | /// - duration: Duration of animation, measured in seconds 24 | /// - timingFunction: Optional `CAMediaTimingFunction` to interpolate animated values with. 25 | /// - changes: Closure executed when the animation is performed 26 | public init( 27 | duration: TimeInterval, 28 | timingFunction: CAMediaTimingFunction? = nil, 29 | changes: @escaping () -> Void = {} 30 | ) { 31 | let stopper = Stopper() 32 | self.duration = duration 33 | self.timingFunction = timingFunction 34 | self.changes = { [weak stopper] in 35 | guard stopper?.isStopped == false else { 36 | return 37 | } 38 | stopper?.isRunning = true 39 | changes() 40 | } 41 | self.stopper = stopper 42 | } 43 | } 44 | 45 | extension Animate { 46 | internal class Stopper { 47 | var isRunning: Bool = false 48 | var isStopped: Bool = false 49 | var stopHandler: (() -> Void)? 50 | } 51 | } 52 | 53 | extension Animate: PerformsAnimations { 54 | public func animate(delay leadingDelay: TimeInterval, completion: ((Bool) -> Void)?) { 55 | let timing = timingParameters(leadingDelay: leadingDelay) 56 | let createAnimations: (((Bool) -> Void)?) -> Void = { completion in 57 | UIView.animate( 58 | withDuration: timing.duration, 59 | delay: timing.delay, 60 | options: options ?? [], 61 | animations: changes, 62 | completion: completion 63 | ) 64 | } 65 | 66 | if let timingFunction = timingFunction { 67 | CATransaction.begin() 68 | CATransaction.setAnimationDuration(duration) 69 | CATransaction.setAnimationTimingFunction(timingFunction) 70 | 71 | createAnimations(completion) 72 | 73 | CATransaction.commit() 74 | } else { 75 | createAnimations(completion) 76 | } 77 | } 78 | 79 | public func stop() { 80 | if stopper.isRunning { 81 | UIView.animate( 82 | withDuration: 0, 83 | delay: 0, 84 | options: [ 85 | .beginFromCurrentState, 86 | .overrideInheritedDuration, 87 | .overrideInheritedOptions 88 | ], 89 | animations: changes 90 | ) 91 | stopper.stopHandler?() 92 | } 93 | stopper.isStopped = true 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Animations/AnimateDelayed.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Performs an animation after a delay, only to be used in a context where other animations are run simultaneously 4 | public struct AnimateDelayed: AnimationContainer, DelayedAnimatable, GroupAnimatable { 5 | 6 | public internal(set) var animation: Delayed 7 | 8 | public var duration: TimeInterval { 9 | return delay + originalDuration 10 | } 11 | 12 | public var originalDuration: TimeInterval { 13 | if let delayed = animation as? DelayedAnimatable { 14 | return delayed.originalDuration 15 | } 16 | return animation.duration 17 | } 18 | 19 | public let delay: TimeInterval 20 | 21 | internal init(delay: TimeInterval, animation: Delayed) { 22 | self.animation = animation 23 | self.delay = delay 24 | } 25 | } 26 | 27 | extension AnimateDelayed where Delayed: DelayedAnimatable { 28 | public var duration: TimeInterval { 29 | delay + animation.originalDuration 30 | } 31 | } 32 | 33 | extension AnimateDelayed where Delayed == Animate { 34 | /// Adds a delay to your animation. Can only be added in a ``Group`` context where animations should be performed simultaneously. 35 | /// - Parameters: 36 | /// - delay: Delay in seconds to add to your animation 37 | /// - duration: Duration of animation, measured in seconds 38 | /// - changes: Closure executed when the animation is performed 39 | public init( 40 | delay: TimeInterval, 41 | duration: TimeInterval, 42 | changes: @escaping () -> Void = {} 43 | ) { 44 | let animation = Animate(duration: duration, changes: changes) 45 | self.init(delay: delay, animation: animation) 46 | } 47 | } 48 | 49 | extension AnimateDelayed: Animation where Delayed: Animation { } 50 | extension AnimateDelayed: SpringAnimatable where Delayed: SpringAnimatable { } 51 | 52 | extension AnimateDelayed: PerformsAnimations where Contained: PerformsAnimations { 53 | public func animate(delay leadingDelay: TimeInterval, completion: ((Bool) -> Void)?) { 54 | animation.animate(delay: delay + leadingDelay, completion: completion) 55 | } 56 | 57 | public func stop() { 58 | animation.stop() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Animations/AnimateSpring.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Performs an animation with spring dampening applied, using the same values as UIView spring animations 4 | public struct AnimateSpring: SpringAnimatable, AnimationContainer, GroupAnimatable { 5 | 6 | public internal(set) var animation: Springed 7 | 8 | public let dampingRatio: CGFloat 9 | public let initialVelocity: CGFloat 10 | 11 | internal init(dampingRatio: CGFloat, initialVelocity: CGFloat, animation: Springed) { 12 | self.animation = animation 13 | self.dampingRatio = dampingRatio 14 | self.initialVelocity = initialVelocity 15 | } 16 | } 17 | 18 | extension AnimateSpring where Springed == Animate { 19 | /// Creates a spring-based animation with the expected damping and velocity values. 20 | /// - Parameters: 21 | /// - damping: Value between 0 and 1, same as damping ratio used for `UIView`-based spring animations 22 | /// - initialVelocity: Relative velocity of animation, defined as full extend of animation per second 23 | /// - duration: Duration of animation, measured in seconds 24 | /// - changes: Closure executed when the animation is performed 25 | public init( 26 | duration: TimeInterval, 27 | dampingRatio: CGFloat, 28 | initialVelocity: CGFloat = 0, 29 | changes: @escaping () -> Void = {} 30 | ) { 31 | let animation = Animate(duration: duration, changes: changes) 32 | self.init(dampingRatio: dampingRatio, initialVelocity: initialVelocity, animation: animation) 33 | } 34 | } 35 | 36 | extension AnimateSpring: SequenceAnimatable, SequenceConvertible where Contained: SequenceAnimatable { 37 | public func animations() -> [SequenceAnimatable] { [self] } 38 | } 39 | 40 | extension AnimateSpring: Animation where Springed: Animation { } 41 | extension AnimateSpring: DelayedAnimatable where Springed: DelayedAnimatable { } 42 | 43 | extension AnimateSpring: PerformsAnimations { 44 | public func animate(delay leadingDelay: TimeInterval, completion: ((Bool) -> Void)?) { 45 | let timing = timingParameters(leadingDelay: leadingDelay) 46 | UIView.animate( 47 | withDuration: timing.duration, 48 | delay: timing.delay, 49 | usingSpringWithDamping: dampingRatio, 50 | initialSpringVelocity: initialVelocity, 51 | options: animation.options ?? [], 52 | animations: animation.changes, 53 | completion: completion 54 | ) 55 | } 56 | 57 | public func stop() { 58 | animation.stop() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Animations/AnimationBuilder.swift: -------------------------------------------------------------------------------- 1 | /// Result builder through which either sequence or group animations can be created. Add `@AnimationBuilder` to a closure or method to provide your own animations. 2 | /// The result of your builder function should be an `Array` of either ``SequenceAnimatable`` or ``GroupAnimatable``. 3 | @resultBuilder 4 | public struct SequenceBuilder { 5 | public static func buildBlock(_ components: SequenceConvertible...) -> [SequenceAnimatable] { 6 | components.flatMap { $0.animations() } 7 | } 8 | public static func buildArray(_ components: [SequenceConvertible]) -> [SequenceAnimatable] { 9 | components.flatMap { $0.animations() } 10 | } 11 | public static func buildOptional(_ component: SequenceConvertible?) -> [SequenceAnimatable] { 12 | component.map { $0.animations() } ?? [] 13 | } 14 | public static func buildEither(first component: SequenceConvertible) -> [SequenceAnimatable] { 15 | component.animations() 16 | } 17 | public static func buildEither(second component: SequenceConvertible) -> [SequenceAnimatable] { 18 | component.animations() 19 | } 20 | } 21 | 22 | @resultBuilder 23 | public struct GroupBuilder { 24 | public static func buildBlock(_ components: GroupConvertible...) -> [GroupAnimatable] { 25 | components.flatMap { $0.animations() } 26 | } 27 | public static func buildArray(_ components: [GroupConvertible]) -> [GroupAnimatable] { 28 | components.flatMap { $0.animations() } 29 | } 30 | public static func buildOptional(_ component: GroupConvertible?) -> [GroupAnimatable] { 31 | component.map { $0.animations() } ?? [] 32 | } 33 | public static func buildEither(first component: GroupConvertible) -> [GroupAnimatable] { 34 | component.animations() 35 | } 36 | public static func buildEither(second component: GroupConvertible) -> [GroupAnimatable] { 37 | component.animations() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Animations/Extra.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Performs the provided handler in between your actual animations. 4 | /// Typically used for setting up state before an animation or creating side-effects like haptic feedback. 5 | public struct Extra: SequenceAnimatable, GroupAnimatable { 6 | public let duration: TimeInterval = 0 7 | 8 | /// Work item used for actually executing the closure 9 | private let workItem: DispatchWorkItem 10 | 11 | public init(perform: @escaping () -> Void) { 12 | workItem = DispatchWorkItem(block: perform) 13 | } 14 | } 15 | 16 | extension Extra: PerformsAnimations { 17 | public func animate(delay leadingDelay: TimeInterval, completion: ((Bool) -> Void)?) { 18 | let timing = timingParameters(leadingDelay: leadingDelay) 19 | 20 | guard timing.delay > 0 else { 21 | workItem.perform() 22 | completion?(true) 23 | return 24 | } 25 | 26 | workItem.notify(queue: .main) { [weak workItem] in 27 | let isFinished = workItem?.isCancelled != true 28 | completion?(isFinished) 29 | } 30 | 31 | DispatchQueue.main.asyncAfter(deadline: .now() + timing.delay, execute: workItem) 32 | } 33 | 34 | public func stop() { 35 | workItem.cancel() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Animations/Group.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Contain multiple animations that should be performed at the same time. Add each animation through the `animations` closure in the initializer. 4 | public struct Group: SequenceAnimatable { 5 | 6 | /// Duration of a simultaneous group is the longest `totalAnimation` (which should include its delay) 7 | public var duration: TimeInterval { 8 | return longestAnimation?.duration ?? 0 9 | } 10 | 11 | /// All animations added to the group 12 | public let animations: [GroupAnimatable] 13 | 14 | let longestAnimation: GroupAnimatable? 15 | 16 | internal init(animations: [GroupAnimatable]) { 17 | self.animations = animations 18 | self.longestAnimation = self.animations.max { $0.duration < $1.duration } 19 | } 20 | 21 | /// Creates a new `Group` providing a way to perform multiple animations simultaneously, meaning all animations run at the same time. 22 | /// - Parameter animations: Add each animation from within this closure. Animations added to a group should conform to ``GroupAnimatable``. 23 | public init(@GroupBuilder animations builder: () -> [GroupAnimatable]) { 24 | self.init(animations: builder()) 25 | } 26 | } 27 | 28 | extension Group: PerformsAnimations { 29 | 30 | public func animate(delay: TimeInterval, completion: ((Bool) -> Void)?) { 31 | let animations = animations.compactMap { $0 as? PerformsAnimations } 32 | guard let longestDuration = animations.map(\.duration).max() else { 33 | completion?(true) 34 | return 35 | } 36 | var hasAddedCompletionHandler: Bool = false 37 | 38 | for animation in animations { 39 | if animation.duration >= longestDuration, !hasAddedCompletionHandler { 40 | hasAddedCompletionHandler = true 41 | animation.animate(delay: delay, completion: completion) 42 | } else { 43 | animation.animate(delay: delay, completion: nil) 44 | } 45 | } 46 | } 47 | 48 | public func stop() { 49 | for animation in animations.compactMap({ $0 as? PerformsAnimations }) { 50 | animation.stop() 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Animations/Loop.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @available( 4 | *, unavailable, 5 | message: "Loop convenience struct has been removed following type checking changes in Swift 5.8. Use a for-loops or the sequence extension method `mapSequence()` or `mapGroup()` instead" 6 | ) 7 | /// Loop through a sequence or for a specified repeat count to easily repeat multiple animation. 8 | /// - Warning: This struct is no longer available. The same functionality can be achieved by using `for`-loop or the methods `mapSequence()` and `mapGroup()` on any Swift Sequence. 9 | public struct Loop: SequenceAnimatable, GroupAnimatable { 10 | public var duration: TimeInterval = 0 11 | public init( 12 | for repeatCount: Int, 13 | @SequenceBuilder animations builder: (_ index: Int) -> [SequenceAnimatable] 14 | ) { } 15 | 16 | public static func through( 17 | _ sequence: S, 18 | @SequenceBuilder animations builder: (S.Element) -> [SequenceAnimatable] 19 | ) -> [SequenceAnimatable] { 20 | [] 21 | } 22 | 23 | public static func through( 24 | _ sequence: S, 25 | @SequenceBuilder animations builder: (S.Element) -> [GroupAnimatable] 26 | ) -> [GroupAnimatable] { 27 | [] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Animations/MapSequence.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Swift.Sequence { 4 | @available(*, unavailable, renamed: "mapSequence", message: "use either mapSequence or mapGroup") 5 | public func mapAnimations( 6 | @SequenceBuilder animations builder: (Element) -> [SequenceAnimatable] 7 | ) -> [SequenceAnimatable] { 8 | flatMap(builder) 9 | } 10 | /// Maps values from the sequence to animations 11 | /// - Parameter animations: Add each animation from within this closure. Animations should conform to ``GroupAnimatable`` 12 | /// - Returns: Sequence of all animations created in the `animation` closure 13 | public func mapSequence( 14 | @SequenceBuilder animations builder: (Element) -> [SequenceAnimatable] 15 | ) -> [SequenceAnimatable] { 16 | flatMap(builder) 17 | } 18 | } 19 | 20 | extension Swift.Sequence { 21 | /// Maps values from the sequence to animations 22 | /// - Parameter animations: Add each animation from within this closure. Animations added to this loop should conform to ``GroupAnimatable`` 23 | /// - Returns: Group of all animations created in the `animation` closure 24 | public func mapGroup( 25 | @GroupBuilder animations builder: (Element) -> [GroupAnimatable] 26 | ) -> [GroupAnimatable] { 27 | flatMap(builder) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Animations/Sequence.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Provides an sequence animation to a ``Group``, creating the ability to run multiple sequences in parallel. Add each animation through the `animations` closure in the initializer. 4 | public struct Sequence: DelayedAnimatable { 5 | 6 | public var duration: TimeInterval { delay + originalDuration } 7 | public var originalDuration: TimeInterval { runningSequence.duration } 8 | 9 | public let delay: TimeInterval 10 | 11 | /// All animations added to the sequence 12 | public var animations: [SequenceAnimatable] { runningSequence.animations } 13 | 14 | let runningSequence: RunningSequence 15 | 16 | internal init(delay: TimeInterval, animations: [SequenceAnimatable]) { 17 | self.delay = delay 18 | self.runningSequence = RunningSequence(animations: animations) 19 | } 20 | 21 | /// Creates a new `Sequence` providing a way to perform a sequence animation from within a group. Each animation is perform in in order, meaning each subsequent animation starts right after the previous completes. 22 | /// - Parameter animations: Add each animation from within this closure. Animations added to a sequence should conform to ``SequenceAnimatable``. 23 | public init(@SequenceBuilder animations builder: () -> [SequenceAnimatable]) { 24 | self.init(delay: 0, animations: builder()) 25 | } 26 | } 27 | 28 | extension Sequence: PerformsAnimations { 29 | public func animate(delay: TimeInterval, completion: ((Bool) -> Void)?) { 30 | runningSequence 31 | .onComplete { finished in 32 | completion?(finished) 33 | } 34 | .animate(delay: delay) 35 | } 36 | 37 | public func stop() { 38 | runningSequence.stopAnimations() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Animations/Wait.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Pauses the sequence for the given amount of seconds before performing the next animation. 4 | public struct Wait: SequenceAnimatable { 5 | public let duration: TimeInterval 6 | 7 | public init(_ duration: TimeInterval) { 8 | self.duration = duration 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Documentation.docc/AnimationPlanner.md: -------------------------------------------------------------------------------- 1 | # ``AnimationPlanner`` 2 | 3 | Chain multiple `UIView` animations with a clear declarative syntax, describing each step along the way. AnimationPlanner allows you to easily create all your animations in the same indentation level using a convenient API leveraging Swift result builders. 4 | 5 | ## Overview 6 | 7 | Start by typing `AnimationPlanner.plan` and begin creating your animation sequence. Within the `animations` closure you can provide all of your animations. Using the returned ``RunningSequence`` a completion handler can be added. 8 | 9 | > Note: Any animation created with AnimationPlanner can use a `CAMediaTimingFunction` animation curve with its animations. This framework provides numerous presets (like `.quintOut`) through an custom extension. 10 | 11 | ### Example 12 | 13 | ```swift 14 | AnimationPlanner.plan { 15 | Animate(duration: 0.32) { 16 | view.transform = CGAffineTransform(scaleX: 2, y: 2) 17 | view.layer.cornerRadius = 40 18 | view.backgroundColor = .systemRed 19 | }.timingFunction(.quintOut) 20 | Wait(0.2) 21 | AnimateSpring(duration: 0.25, damping: 0.52) { 22 | view.backgroundColor = .systemBlue 23 | view.layer.cornerRadius = 0 24 | view.transform = .identity 25 | } 26 | } 27 | ``` 28 | 29 | See ``AnimationPlanner/AnimationPlanner/plan(animations:)`` on ``AnimationPlanner`` for more info on beginning your animation sequence. 30 | 31 | The most often used animation types are listed below. 32 | 33 | | Animation | Description | 34 | | ----------- | --------------------------------------------------------------------------------------------------- | 35 | | ``Animate`` | Perform an aninimation with duration in seconds. | 36 | | ``Wait`` | Pauses the sequence for a given amount of seconds. | 37 | | ``Extra`` | prepare state before or between your steps or perform side-effects like triggering haptic feedback | 38 | 39 | ## Topics 40 | 41 | ### Examples 42 | 43 | - 44 | 45 | ### Starting your animations 46 | 47 | - ``AnimationPlanner/AnimationPlanner/plan(animations:)`` 48 | - ``AnimationPlanner/AnimationPlanner/group(animations:)`` 49 | 50 | ### Animation structs 51 | 52 | - ``Animate`` 53 | - ``Wait`` 54 | - ``AnimateSpring`` 55 | - ``AnimateDelayed`` 56 | - ``Extra`` 57 | 58 | ### Animation modifiers methods 59 | 60 | To change the way an animation is performed, the spring and delay modifiers can add specific behavior to your ``Animate`` struct. 61 | 62 | Spring and delay animations can also be created as seperate structs by using the initializers of ``AnimateSpring`` or ``AnimateDelayed`` 63 | as described above. 64 | 65 | - ``SpringModifier/spring(damping:initialVelocity:)-33bwh`` 66 | - ``DelayModifier/delayed(_:)-7lnka`` 67 | 68 | ### Property modifier methods 69 | 70 | All animations conforming to ``AnimationModifiers`` can use modifier methods that add or update specific properties. 71 | 72 | To add both ``Animation/options`` and a ``Animation/timingFunction`` to your animation, call each method subsequently. 73 | ```swift 74 | Animate(duration: 0.5) { view.transform = .identity } 75 | .options(.allowUserInteraction) 76 | .timingFunction(.quintOut) 77 | ``` 78 | 79 | - ``AnimationModifiers/options(_:)`` 80 | - ``AnimationModifiers/timingFunction(_:)`` 81 | - ``AnimationModifiers/changes(_:)`` 82 | 83 | ### Grouped animation 84 | 85 | To perform multiple animations simultaneously, a `Group` can be created in which animations can be contained. 86 | 87 | - ``Group`` 88 | - ``Sequence`` 89 | 90 | ### Loop 91 | 92 | Iterating over a sequence of repeating animations for a specific amount of time can be done using `for element in sequence {` but also through 93 | the ``Loop`` struct. 94 | 95 | - ``Loop`` 96 | 97 | ### Running sequence 98 | 99 | Calling the main `AnimationPlanner` methods ``AnimationPlanner/AnimationPlanner/plan(animations:)`` and ``AnimationPlanner/AnimationPlanner/group(animations:)`` returns in a ``RunningSequence`` object. This class provides a ``RunningSequence/state-swift.property`` for the running sequence, allows for a completion handler to be added with ``RunningSequence/onComplete(_:)`` and even stop all its animations with ``RunningSequence/stopAnimations()``. 100 | 101 | - ``RunningSequence`` 102 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Documentation.docc/creating-basic-animation-sequence.md: -------------------------------------------------------------------------------- 1 | # Creating a basic animation sequence 2 | 3 | An example of how a typical animation sequence would look, how it’s created and more information on the sample app available in the repository. 4 | 5 | ## Overview 6 | 7 | A basic sequence animation can be constructed with very little effort, using only two structs provided through the animation builder in `AnimationPlanner.plan`. In this example, two modifiers are used to add more customization to the animations. 8 | 9 | ### Basic sequence 10 | 11 | A basic linear sequence could look as follow: 12 | 13 | ```swift 14 | AnimationPlanner.plan { 15 | Animate(duration: 0.5) { 16 | subview.transform = CGAffineTransform(scaleX: 2, y: 2) 17 | subview.layer.cornerRadius = 40 18 | subview.backgroundColor = .systemRed 19 | }.timingFunction(.quintOut) 20 | Wait(0.2) 21 | Animate(duration: 0.45) { 22 | subview.backgroundColor = .systemBlue 23 | subview.layer.cornerRadius = 0 24 | subview.transform = .identity 25 | }.spring(damping: 0.68) 26 | Wait(0.2) 27 | Animate(duration: 0.2) { 28 | subview.alpha = 0 29 | subview.frame.origin.y = self.view.bounds.maxY 30 | } 31 | }.onComplete { finished in 32 | subview.removeFromSuperview() 33 | } 34 | ``` 35 | 36 | In the code shown above, an animation sequence is started with ``AnimationPlanner/AnimationPlanner/plan(animations:)`` where each animation is added. Using the structs ``Animate`` and ``Wait`` a simple animation sequence is constructed by changing properties on a `subview` object. ``RunningSequence/onComplete(_:)`` is used to add a completion handler where the `subview` is removed, demonstrating how to end an animation sequence. 37 | 38 | The first two ``Animate`` structs have modifiers applied. ``AnimationModifiers/timingFunction(_:)`` changes the interpolation method of the animation by providing a `CAMediaTimingFunction`. AnimationPlanner already provides many custom available timing functions like `.quintOut` used in the code example. 39 | 40 | The second modifier changes the animation to a spring-based animation with ``SpringModifier/spring(damping:initialVelocity:)-33bwh`` and sets its daming ratio to the magic number of `0.68`. Spring-based animations in AnimationPlanner result in a `UIView` animation with `usingSpringWithDamping` where you can set a `dampingRatio` and `initialVelocity`. 41 | 42 | ## Sample app 43 | 44 | In the repository, a sample app is availabe that demonstrates more complex `AnimationPlanner` usage. 45 | 46 | Clone the repository [github.com/PimCoumans/AnimationPlanner](https://github.com/PimCoumans/AnimationPlanner) and take a look at the Sample App. In the `ViewController` of this app `AnimationPlanner` is used to perform animations. Change set `performComplexAnimation` to `true` to make it show a complex animation that introduces advanced methods of using `AnimationPlanner`, including a ``Group``, adding steps using a ``Loop`` and a custom method using ``AnimationBuilder`` to create a shake animation. 47 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Extensions/CAMediaTimingFunction.swift: -------------------------------------------------------------------------------- 1 | import QuartzCore 2 | // from @warpling’s https://gist.github.com/warpling/21bef9059e47f5aad2f2955d48fd7c0c 3 | public extension CAMediaTimingFunction { 4 | 5 | static let linear = CAMediaTimingFunction(name: .linear) 6 | static let easeOut = CAMediaTimingFunction(name: .easeOut) 7 | static let easeIn = CAMediaTimingFunction(name: .easeIn) 8 | static let easeInOut = CAMediaTimingFunction(name: .easeInEaseOut) 9 | static let `default` = CAMediaTimingFunction(name: .default) 10 | 11 | static let sineIn = CAMediaTimingFunction(controlPoints: 0.45, 0, 1, 1) 12 | static let sineOut = CAMediaTimingFunction(controlPoints: 0, 0, 0.55, 1) 13 | static let sineInOut = CAMediaTimingFunction(controlPoints: 0.45, 0, 0.55, 1) 14 | 15 | static let quadIn = CAMediaTimingFunction(controlPoints: 0.43, 0, 0.82, 0.60) 16 | static let quadOut = CAMediaTimingFunction(controlPoints: 0.18, 0.4, 0.57, 1) 17 | static let quadInOut = CAMediaTimingFunction(controlPoints: 0.43, 0, 0.57, 1) 18 | 19 | static let cubicIn = CAMediaTimingFunction(controlPoints: 0.67, 0, 0.84, 0.54) 20 | static let cubicOut = CAMediaTimingFunction(controlPoints: 0.16, 0.46, 0.33, 1) 21 | static let cubicInOut = CAMediaTimingFunction(controlPoints: 0.65, 0, 0.35, 1) 22 | 23 | static let quartIn = CAMediaTimingFunction(controlPoints: 0.81, 0, 0.77, 0.34) 24 | static let quartOut = CAMediaTimingFunction(controlPoints: 0.23, 0.66, 0.19, 1) 25 | static let quartInOut = CAMediaTimingFunction(controlPoints: 0.81, 0, 0.19, 1) 26 | 27 | static let quintIn = CAMediaTimingFunction(controlPoints: 0.89, 0, 0.81, 0.27) 28 | static let quintOut = CAMediaTimingFunction(controlPoints: 0.19, 0.73, 0.11, 1) 29 | static let quintInOut = CAMediaTimingFunction(controlPoints: 0.9, 0, 0.1, 1) 30 | 31 | static let expoIn = CAMediaTimingFunction(controlPoints: 1.04, 0, 0.88, 0.49) 32 | static let expoOut = CAMediaTimingFunction(controlPoints: 0.12, 0.51, -0.4, 1) 33 | static let expoInOut = CAMediaTimingFunction(controlPoints: 0.95, 0, 0.05, 1) 34 | 35 | static let circIn = CAMediaTimingFunction(controlPoints: 0.6, 0, 1, 0.45) 36 | static let circOut = CAMediaTimingFunction(controlPoints: 1, 0.55, 0.4, 1) 37 | static let circInOut = CAMediaTimingFunction(controlPoints: 0.82, 0, 0.18, 1) 38 | 39 | static let backIn = CAMediaTimingFunction(controlPoints: 0.77, -0.63, 1, 1) 40 | static let backOut = CAMediaTimingFunction(controlPoints: 0, 0, 0.23, 1.37) 41 | static let backInOut = CAMediaTimingFunction(controlPoints: 0.77, -0.63, 0.23, 1.37) 42 | 43 | static let swiftOut = CAMediaTimingFunction(controlPoints: 0.4, 0.0, 0.2, 1.0) 44 | } 45 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Protocols/Animatable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Anything that can be used to create animations in AnimationPlanner 4 | public protocol Animatable { 5 | /// Full duration of the animation 6 | var duration: TimeInterval { get } 7 | } 8 | 9 | /// Animation that can be used to construct `UIView` animations 10 | public protocol Animation: Animatable, PerformsAnimations { 11 | /// Changes on views to perform animation with 12 | var changes: () -> Void { get } 13 | /// Animation options to use for UIView animation 14 | var options: UIView.AnimationOptions? { get } 15 | /// Timing function to apply to animation. Leads to the `UIView` animation being performed in a `CATransaction` wrapped animation 16 | var timingFunction: CAMediaTimingFunction? { get } 17 | } 18 | 19 | /// Animation that can be performed in a sequence, meaning each subsequent animation starts right after the previous completes 20 | public protocol SequenceAnimatable: Animatable, SequenceConvertible { } 21 | 22 | extension SequenceAnimatable { 23 | public func animations() -> [SequenceAnimatable] { [self] } 24 | } 25 | 26 | /// Animation that can be used in a ``Group`` and be performed simultaneously, meaning all animations run at the same time. 27 | public protocol GroupAnimatable: Animatable, GroupConvertible { } 28 | 29 | extension GroupAnimatable { 30 | public func animations() -> [GroupAnimatable] { [self] } 31 | } 32 | 33 | /// Adds delaying functionality to an animation. Delayed animations can only be added in a ``Group`` context, where each animation is performed simultaneously. Adding a delay to a sequence animation can be done by preceding it with a ``Wait`` struct. 34 | public protocol DelayedAnimatable: GroupAnimatable { 35 | /// Delay in seconds after which the animation should start 36 | var delay: TimeInterval { get } 37 | /// Duration of animation without delay applied 38 | var originalDuration: TimeInterval { get } 39 | } 40 | 41 | /// Adds spring-based animation parameters to an animation. 42 | public protocol SpringAnimatable: Animatable { 43 | /// Spring damping used for spring-based animation. To quote `UIView`’s animate documentation: 44 | /// “To smoothly decelerate the animation without oscillation, use a value of 1. Employ a damping ratio closer to zero to increase oscillation.” 45 | var dampingRatio: CGFloat { get } 46 | 47 | /// Initial velocity for spring-based animation. `UIView`’s documentation clearly explains it with: 48 | /// “A value of 1 corresponds to the total animation distance traversed in one second. For example, if the total animation distance is 200 points and you want the start of the animation to match a view velocity of 100 pt/s, use a value of 0.5.” 49 | var initialVelocity: CGFloat { get } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Protocols/AnimationContainer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Adds custom behavior on top of any contained animation. Forwards all required ``Animation`` properties 4 | /// to the contained animation when necessary. 5 | public protocol AnimationContainer { 6 | /// Animation type contained by ``AnimationContainer`` 7 | associatedtype Contained: Animatable 8 | /// Animation contained any animation using ``AnimationContainer``. 9 | var animation: Contained { get } 10 | } 11 | 12 | /// Forwarding ``Animation`` properties 13 | extension AnimationContainer where Contained: Animation { 14 | /// Forwarded ``Animation`` property for ``Animate/duration`` 15 | public var duration: TimeInterval { animation.duration } 16 | /// Forwarded ``Animation`` property for ``Animation/changes`` 17 | public var changes: () -> Void { animation.changes } 18 | /// Forwarded ``Animation`` property for ``Animation/options`` 19 | public var options: UIView.AnimationOptions? { animation.options } 20 | /// Forwarded ``Animation`` property for ``Animation/timingFunction`` 21 | public var timingFunction: CAMediaTimingFunction? { animation.timingFunction } 22 | } 23 | 24 | /// Forwarding ``DelayedAnimatable`` properties 25 | extension AnimationContainer where Contained: DelayedAnimatable { 26 | /// Forwarded ``DelayedAnimatable`` property for ``DelayedAnimatable/delay`` 27 | public var delay: TimeInterval { 28 | animation.delay 29 | } 30 | 31 | /// Forwarded ``DelayedAnimatable`` property for ``DelayedAnimatable/originalDuration`` 32 | public var originalDuration: TimeInterval { 33 | animation.originalDuration 34 | } 35 | } 36 | 37 | /// Forwarding ``SpringAnimatable`` properties 38 | extension AnimationContainer where Contained: SpringAnimatable { 39 | /// Forwarded ``SpringAnimatable`` property for ``SpringAnimatable/dampingRatio`` 40 | public var dampingRatio: CGFloat { animation.dampingRatio } 41 | /// Forwarded ``SpringAnimatable`` property for ``SpringAnimatable/initialVelocity`` 42 | public var initialVelocity: CGFloat { animation.initialVelocity } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Protocols/AnimationConvertible.swift: -------------------------------------------------------------------------------- 1 | /// Provides a way to create a uniform sequence from all animations conforming to ``SequenceAnimatable`` 2 | public protocol SequenceConvertible { 3 | func animations() -> [SequenceAnimatable] 4 | } 5 | extension SequenceConvertible where Self: SequenceAnimatable { 6 | public func animations() -> [SequenceAnimatable] { 7 | [self] 8 | } 9 | } 10 | 11 | /// Provides a way to group together animations conforming to ``GroupAnimatable`` 12 | public protocol GroupConvertible { 13 | func animations() -> [GroupAnimatable] 14 | } 15 | extension GroupConvertible where Self: GroupAnimatable { 16 | public func animations() -> [GroupAnimatable] { 17 | [self] 18 | } 19 | } 20 | 21 | extension Array: SequenceConvertible where Element == SequenceAnimatable { 22 | public func animations() -> [SequenceAnimatable] { flatMap { $0.animations() } } 23 | } 24 | 25 | extension Array: GroupConvertible where Element == GroupAnimatable { 26 | public func animations() -> [GroupAnimatable] { flatMap { $0.animations() } } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Protocols/AnimationModifiers.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Adds modifier methods to animations, providing a way to update multiple properties with chained successive method calls. 4 | /// 5 | /// Each method can be called on your animation. All animations conforming to `AnimationModifiers` should at least implement the following methods: 6 | /// - ``options(_:)``: Set the `UIView.AnimationOptions` for the animation. Will append new options to any existing options. 7 | /// - ``timingFunction(_:)``: Sets a `CAMediaTimingFunction` for the animation. Overwrites possible previously set functions. 8 | /// - ``changes(_:)``: Sets the ``Animation/changes`` to be performed for your animation. 9 | public protocol AnimationModifiers: Animation { 10 | /// Set the `UIView.AnimationOptions` for the animation. Will append new options to any existing options. 11 | /// 12 | /// - Parameter options: OptionSet of UIView AnimationOptions 13 | /// - Note: Using `.repeats` will break expected behavior when used in a sequence 14 | func options(_ options: UIView.AnimationOptions) -> Self 15 | 16 | /// Enables interaction on your parent views while this animation is running 17 | func allowUserInteraction() -> Self 18 | 19 | /// Sets a `CAMediaTimingFunction` for the animation. Overwrites possible previously set functions. 20 | /// 21 | /// Overrides any animation curves previously set with ``timingFunction(_:)`` 22 | /// 23 | /// - Tip: AnimationPlanner provides numerous animation curves through a `CAMediaTimingFunction` extension. 24 | /// Type a period for the `timingFunction` parameter to see what is readily available. Have you tried `.quintOut` yet? 25 | /// 26 | /// - Important: Timing functions are ignored when applied to an animation using spring interpolation (``AnimateSpring``) 27 | /// 28 | /// - Parameter function: Custom CAMediaTimingFunction or any of the available static extensions 29 | func timingFunction(_ function: CAMediaTimingFunction) -> Self 30 | 31 | /// Sets the ``Animation/changes`` to be performed for your animation. Could be used when it‘s convenient to add your animation changes at a later state, e.g., after applying other modifiers to your ``Animate`` struct. 32 | /// - Parameter changes: Change properties to animate in this closure 33 | /// - Note: This replaces any previous animation changes set 34 | func changes(_ changes: @escaping () -> Void) -> Self 35 | 36 | /// Adds a handler to be called when the animation is stopped. This handler is only called for the animations that are currently being run. 37 | /// 38 | /// Calling `view.layer.removeAllAnimations()` immediately stops animations for all views updated with this animation, 39 | /// - Parameter stopHandler: Closure called when animation is stopped 40 | /// - Note: This method is only useful for long-running or repeating animations, as these are usually not stopped by stopping the running sequence. 41 | func onStopped(_ stopHandler: @escaping () -> Void) -> Self 42 | } 43 | 44 | extension Animate: AnimationModifiers { 45 | public func options(_ options: UIView.AnimationOptions) -> Self { 46 | // Update options by creating a union of existing options 47 | mutate { $0.options = $0.options?.union(options) ?? options } 48 | } 49 | public func allowUserInteraction() -> Animate { 50 | mutate { $0.options = ($0.options ?? []).union(.allowUserInteraction) } 51 | } 52 | public func timingFunction(_ function: CAMediaTimingFunction) -> Self { 53 | mutate { $0.timingFunction = function} 54 | } 55 | public func changes(_ changes: @escaping () -> Void) -> Animate { 56 | mutate { $0.changes = changes } 57 | } 58 | public func onStopped(_ stopHandler: @escaping () -> Void) -> Animate { 59 | mutate { $0.stopper.stopHandler = stopHandler } 60 | } 61 | } 62 | 63 | // MARK: - Spring modifiers 64 | 65 | /// Adds spring interpolation to an existing animation 66 | public protocol SpringModifier { 67 | /// Animation contained by ``AnimateSpring`` animation 68 | associatedtype SpringedAnimation: Animation 69 | 70 | /// Creates a spring-based animation with the expected damping and velocity values. Timing curves are ignored with spring animations as the spring itself should do all the interpolating. 71 | /// - Parameters: 72 | /// - damping: Value between 0 and 1, same as damping ratio used for `UIView`-based spring animations 73 | /// - initialVelocity: Relative velocity of animation, defined as full extend of animation per second 74 | /// - Returns: ``AnimateSpring``-contained animation appending spring values to the modified animation 75 | func spring(damping: CGFloat, initialVelocity: CGFloat) -> AnimateSpring 76 | } 77 | 78 | extension SpringModifier where Self: Animation { 79 | public func spring(damping: CGFloat, initialVelocity: CGFloat = 0) -> AnimateSpring { 80 | // By default, all structs conforming `Animation` should be able to animate with a spring 81 | AnimateSpring(dampingRatio: damping, initialVelocity: initialVelocity, animation: self) 82 | } 83 | } 84 | 85 | extension AnimateDelayed: SpringModifier where Contained: Animation { 86 | public func spring(damping: CGFloat, initialVelocity: CGFloat) -> AnimateSpring { 87 | AnimateSpring(dampingRatio: damping, initialVelocity: initialVelocity, animation: animation) 88 | } 89 | } 90 | 91 | extension Animate: SpringModifier { } 92 | 93 | // MARK: - Delay modifiers 94 | 95 | /// Adds a delay to an existing animation 96 | public protocol DelayModifier { 97 | /// Animation contained by ``AnimateDelayed`` animation 98 | associatedtype DelayedAnimation: Animatable 99 | /// Adds a delay to your animation. Only available in a ``Group`` context where animations should be performed simultaneously. 100 | /// - Parameter delay: Delay in seconds to add to your animation. 101 | /// - Returns: `AnimateDelayed`-contained animation adding a delay the modified animation 102 | func delayed(_ delay: TimeInterval) -> AnimateDelayed 103 | } 104 | 105 | extension DelayModifier where Self: GroupAnimatable { 106 | public func delayed(_ delay: TimeInterval) -> AnimateDelayed { 107 | // By default, all structs conforming to `GroupAnimatable` should be able to animate with a delay 108 | AnimateDelayed(delay: delay, animation: self) 109 | } 110 | } 111 | 112 | extension Animate: DelayModifier { } 113 | 114 | extension AnimateSpring: DelayModifier { } 115 | 116 | extension Extra: DelayModifier { } 117 | 118 | extension Sequence: DelayModifier { } 119 | 120 | /* -- Internal animation modifying convenience methods -- */ 121 | 122 | /// Convenience protocol to let structs to change properties on themself without using `mutating` 123 | protocol Mutable { 124 | mutating func mutate(_ mutator: (inout Self) -> Void) -> Self 125 | } 126 | 127 | extension Mutable { 128 | func mutate(_ mutator: (inout Self) -> Void) -> Self { 129 | var mutableSelf = self 130 | mutator(&mutableSelf) 131 | return mutableSelf 132 | } 133 | } 134 | 135 | extension Animate: Mutable { } 136 | extension Extra: Mutable { } 137 | 138 | extension AnimateSpring: Mutable { } 139 | extension AnimateSpring: AnimationModifiers where Contained: AnimationModifiers { 140 | public func options(_ options: UIView.AnimationOptions) -> Self { 141 | mutate { $0.animation = animation.options(options) } 142 | } 143 | public func allowUserInteraction() -> AnimateSpring { 144 | mutate { $0.animation = animation.allowUserInteraction() } 145 | } 146 | public func timingFunction(_ function: CAMediaTimingFunction) -> Self { 147 | mutate { $0.animation = animation.timingFunction(function) } 148 | } 149 | public func changes(_ changes: @escaping () -> Void) -> Self { 150 | mutate { $0.animation = animation.changes(changes) } 151 | } 152 | public func onStopped(_ stopHandler: @escaping () -> Void) -> AnimateSpring { 153 | mutate { $0.animation = animation.onStopped(stopHandler) } 154 | } 155 | } 156 | 157 | extension AnimateDelayed: Mutable { } 158 | extension AnimateDelayed: AnimationModifiers where Contained: Animation & AnimationModifiers { 159 | public func options(_ options: UIView.AnimationOptions) -> Self { 160 | mutate { $0.animation = animation.options(options) } 161 | } 162 | public func allowUserInteraction() -> AnimateDelayed { 163 | mutate { $0.animation = animation.allowUserInteraction() } 164 | } 165 | public func timingFunction(_ function: CAMediaTimingFunction) -> Self { 166 | mutate { $0.animation = animation.timingFunction(function) } 167 | } 168 | public func changes(_ changes: @escaping () -> Void) -> Self { 169 | mutate { $0.animation = animation.changes(changes) } 170 | } 171 | public func onStopped(_ stopHandler: @escaping () -> Void) -> AnimateDelayed { 172 | mutate { $0.animation = animation.onStopped(stopHandler) } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/Protocols/PerformsAnimations.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Creates actual `UIView` animations for all animation structs. Implement ``animate(delay:completion:)`` to make sure any custom animation creates an actual animation. 4 | /// Use the default implementation of ``timingParameters(leadingDelay:)-2swvd`` to get the most accurate timing parameters for your animation so any set delay isn't missed. 5 | public protocol PerformsAnimations: Animatable { 6 | /// Perform the actual animation 7 | /// - Parameters: 8 | /// - delay: Any delay accumulated (from preceding ``Wait`` structs) leading up to the animation. 9 | /// Waits for this amount of seconds before actually performing the animation 10 | /// - completion: Optional closure called when animation completes 11 | func animate(delay leadingDelay: TimeInterval, completion: ((_ finished: Bool) -> Void)?) 12 | 13 | /// Cancels any currently running animations 14 | func stop() 15 | 16 | /// Queries the animation and possible contained animations to find the correct timing values to use to create an actual animation 17 | /// - Parameter leadingDelay: Delay to add before performing animation 18 | /// - Returns: Tuple containing a delay and duration in seconds 19 | func timingParameters(leadingDelay: TimeInterval) -> (delay: TimeInterval, duration: TimeInterval) 20 | } 21 | 22 | extension PerformsAnimations { 23 | 24 | public func timingParameters(leadingDelay: TimeInterval) -> (delay: TimeInterval, duration: TimeInterval) { 25 | var parameters = (delay: leadingDelay, duration: duration) 26 | 27 | if let delayed = self as? DelayedAnimatable { 28 | parameters.delay += delayed.delay 29 | parameters.duration = delayed.originalDuration 30 | } 31 | return parameters 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/AnimationPlanner/RunningSequence.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Maintains state about running animations and provides ways to add a completion handler or stop the animations 4 | public class RunningSequence { 5 | 6 | public enum State: Equatable { 7 | /// Sequence is ready but not yet running animations 8 | case ready 9 | /// Sequence is performing animations 10 | case running 11 | /// Sequence has completed animations have completed 12 | /// - Parameter finished: Whether animations have properly finished 13 | case completed(finished: Bool) 14 | /// Sequence has been manually stopped 15 | case stopped 16 | } 17 | 18 | /// Total duration of all animations in sequence 19 | public let duration: TimeInterval 20 | /// All animation to be performed in sequence 21 | public let animations: [SequenceAnimatable] 22 | 23 | /// Current state of sequence 24 | public private(set) var state: State = .ready 25 | 26 | private(set) var remainingAnimations: [Animatable] = [] 27 | private(set) var currentAnimation: PerformsAnimations? 28 | 29 | private(set) var completionHandlers: [(Bool) -> Void] = [] 30 | 31 | internal init(animations: [SequenceAnimatable]) { 32 | self.animations = animations 33 | self.duration = animations.reduce(0, { $0 + $1.duration }) 34 | } 35 | } 36 | 37 | public extension RunningSequence { 38 | /// Adds completion handler to running sequence. The closure is called when the sequence has completed 39 | /// - Parameter handler: Closure to be executed when sequence has finished 40 | /// - Returns: Returns `Self` so this method can be added directly after creation an animation sequence 41 | @discardableResult 42 | func onComplete(_ handler: @escaping (_ finished: Bool) -> Void) -> Self { 43 | switch state { 44 | 45 | case .ready: fallthrough 46 | case .running: 47 | completionHandlers.append(handler) 48 | case .completed(finished: let finished): 49 | handler(finished) 50 | case .stopped: 51 | handler(false) 52 | } 53 | return self 54 | } 55 | } 56 | 57 | public extension RunningSequence { 58 | /// Stops the currently running animation and cancels any upcoming animations 59 | func stopAnimations() { 60 | guard state == .ready || state == .running else { 61 | // Only running animations can be stopped 62 | return 63 | } 64 | 65 | state = .stopped 66 | currentAnimation?.stop() 67 | currentAnimation = nil 68 | remainingAnimations.removeAll() 69 | 70 | complete(finished: false) 71 | } 72 | } 73 | 74 | extension RunningSequence { 75 | 76 | @discardableResult 77 | func animate(delay: TimeInterval = 0) -> Self { 78 | guard state == .ready else { 79 | // Don’t start animating a sequence with running, completed or stopped animations 80 | return self 81 | } 82 | state = .running 83 | remainingAnimations = Array(animations) 84 | animateNextAnimation(initialDelay: delay) 85 | return self 86 | } 87 | 88 | func animateNextAnimation(initialDelay: TimeInterval = 0) { 89 | var leadingDelay: TimeInterval = initialDelay 90 | let impendingAnimations = remainingAnimations.drop { animation in 91 | if let wait = animation as? Wait { 92 | leadingDelay += wait.duration 93 | return true 94 | } 95 | guard animation is PerformsAnimations else { 96 | return true 97 | } 98 | return false 99 | } 100 | 101 | guard let animation = impendingAnimations.first as? PerformsAnimations else { 102 | guard leadingDelay == 0 else { 103 | // Wait out the remaining delay until calling completion closure 104 | DispatchQueue.main.asyncAfter(deadline: .now() + leadingDelay) { 105 | self.complete(finished: true) 106 | } 107 | return 108 | } 109 | complete(finished: true) 110 | return 111 | } 112 | 113 | remainingAnimations = Array(impendingAnimations.dropFirst()) 114 | let duration = animation.duration 115 | let completionDuration = duration + leadingDelay 116 | 117 | let startTime = CACurrentMediaTime() 118 | animation.animate(delay: leadingDelay) { finished in 119 | guard finished else { 120 | self.complete(finished: finished) 121 | return 122 | } 123 | 124 | guard completionDuration > 0 else { 125 | // Skip duration checking when animation should immediately complete 126 | self.animateNextAnimation() 127 | return 128 | } 129 | 130 | let actualDuration = CACurrentMediaTime() - startTime 131 | let difference = (duration + leadingDelay) - actualDuration 132 | let oneFrameDifference: TimeInterval = 1/60 133 | 134 | if difference <= 0.1 || actualDuration >= oneFrameDifference { 135 | self.animateNextAnimation() 136 | } else { 137 | // UIView animation probably wasn‘t executed because no actual animatable 138 | // properties were changed in animation closure. Just wait out remaining time 139 | // before moving over to the next step. 140 | let waitTime = max(0, difference - oneFrameDifference) // reduce a frame to be safe 141 | DispatchQueue.main.asyncAfter(deadline: .now() + waitTime) { 142 | self.animateNextAnimation() 143 | } 144 | } 145 | } 146 | if completionDuration > 0 { 147 | // Only set current animation when its completion can‘t immediately fire, 148 | // causing a newer animation to be set as `currentAnimation` right before 149 | // this line is executed 150 | currentAnimation = animation 151 | } 152 | } 153 | 154 | func complete(finished: Bool) { 155 | if state == .running { 156 | state = .completed(finished: finished) 157 | } 158 | completionHandlers.forEach { $0(finished) } 159 | completionHandlers.removeAll() 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Tests/AnimationPlannerTests/AnimationPlannerTests.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import XCTest 3 | import AnimationPlanner 4 | 5 | class AnimationPlannerTests: XCTestCase { 6 | 7 | var window: UIWindow! 8 | var view: UIView! 9 | 10 | override func setUp() { 11 | window = UIWindow(frame: UIScreen.main.bounds) 12 | view = newView() 13 | window.addSubview(view) 14 | } 15 | 16 | override func tearDown() { 17 | window.resignKey() 18 | view.removeFromSuperview() 19 | window = nil 20 | view = nil 21 | } 22 | 23 | /// Runs your animation logic, waits for completion and fails when expected duration varies from provided duration (allowing for precision). Adds a default completion handler to the returned `RunningSequence`. 24 | /// - Parameters: 25 | /// - duration: Duration of animation, or total duration of all animation steps, defaults to random duration 26 | /// - precision: Precision to use when comparing expected duration and time to complete animations 27 | /// - expectFinished: Whether the animation are expected to be properly finished 28 | /// - animations: Closure where animations should be performed with completion closure to call when completed 29 | /// - completion: Closure to call when animations have completed 30 | /// - usedDuration: Duration for animation, use this argument when no specific duration is provided 31 | /// - usedPrecision: Precision for duration check, use this argument when no specific precision is provided 32 | func runAnimationBuilderTest( 33 | duration: TimeInterval = randomDuration, 34 | precision: TimeInterval = durationPrecision, 35 | expectFinished: Bool = true, 36 | _ animations: @escaping ( 37 | _ usedDuration: TimeInterval, 38 | _ usedPrecision: TimeInterval) -> RunningSequence? 39 | ) { 40 | runAnimationTest(duration: duration, precision: precision, expectFinished: expectFinished) { completion, usedDuration, usedPrecision in 41 | let runningSequence = animations(duration, precision) 42 | XCTAssertNotNil(runningSequence) 43 | runningSequence?.onComplete(completion) 44 | } 45 | } 46 | 47 | /// Runs your animation logic, waits for completion and fails when expected duration varies from provided duration (allowing for precision). Add the completion handler to the returned `RunningSequence` object when only using `AnimationPlanner.plan` or `.group`. Otherwise use `runAnimationTest` 48 | /// - Parameters: 49 | /// - duration: Duration of animation, or total duration of all animation steps, defaults to random duration 50 | /// - precision: Precision to use when comparing expected duration and time to complete animations 51 | /// - expectFinished: Whether the animation are expected to be properly finished 52 | /// - animations: Closure where animations should be performed with completion closure to call when completed 53 | /// - completion: Closure to call when animations have completed 54 | /// - usedDuration: Duration for animation, use this argument when no specific duration is provided 55 | /// - usedPrecision: Precision for duration check, use this argument when no specific precision is provided 56 | func runAnimationTest( 57 | duration: TimeInterval = randomDuration, 58 | precision: TimeInterval = durationPrecision, 59 | expectFinished: Bool = true, 60 | _ animations: @escaping ( 61 | _ completion: @escaping (Bool) -> Void, 62 | _ usedDuration: TimeInterval, 63 | _ usedPrecision: TimeInterval) -> Void 64 | ) { 65 | let finishedExpectation = expectation(description: "Animation finished") 66 | let startTime = CACurrentMediaTime() 67 | 68 | let completion: (Bool) -> Void = { finished in 69 | if finished != expectFinished { 70 | if expectFinished { 71 | XCTFail("Animations should complete finished") 72 | } else { 73 | XCTFail("Animations should complete interrupted") 74 | } 75 | } 76 | assertDifference(startTime: startTime, duration: duration, precision: precision) 77 | finishedExpectation.fulfill() 78 | } 79 | 80 | animations(completion, duration, precision) 81 | 82 | wait(for: [finishedExpectation], timeout: duration + precision * 2) 83 | } 84 | } 85 | 86 | let durationPrecision: TimeInterval = 0.05 87 | 88 | func assertDifference(startTime: CFTimeInterval, duration: TimeInterval, precision: TimeInterval = durationPrecision) { 89 | let finishedTime = CACurrentMediaTime() - startTime 90 | let difference = finishedTime - duration 91 | XCTAssert(abs(difference) < precision, "unexpected completion time (difference \(difference) seconds (precision \(precision))") 92 | } 93 | 94 | fileprivate extension CGFloat { 95 | 96 | private static let colorRange: Range = 0.1..<1.0 97 | private static let tinyRange: Range = 0.8..<1.2 98 | private static let smallRange: Range = 4..<6 99 | private static let mediumRange: Range = 8..<12 100 | private static let largeRange: Range = 200..<400 101 | 102 | static var color: Self { Self.random(in: colorRange) } 103 | static var tiny: Self { Self.random(in: tinyRange) } 104 | static var small: Self { Self.random(in: smallRange) } 105 | static var medium: Self { Self.random(in: mediumRange) } 106 | static var large: Self { Self.random(in: largeRange) } 107 | } 108 | 109 | extension Array where Element == TimeInterval { 110 | func totalDuration() -> Element { 111 | reduce(0, +) 112 | } 113 | func longestDuration() -> Element { 114 | self.max()! 115 | } 116 | } 117 | 118 | extension AnimationPlannerTests { 119 | 120 | class var randomDuration: TimeInterval { TimeInterval.random(in: 0.2...0.6) } 121 | var randomDuration: TimeInterval { Self.randomDuration } 122 | 123 | class func randomDurations(amount: Int) -> [TimeInterval] { (0.. [TimeInterval] { Self.randomDurations(amount: amount) } 125 | 126 | struct RandomAnimation { 127 | let delay: TimeInterval 128 | let duration: TimeInterval 129 | var totalDuration: TimeInterval { delay + duration } 130 | } 131 | 132 | func randomDelayedAnimations(amount: Int) -> [RandomAnimation] { 133 | zip( 134 | randomDurations(amount: amount), 135 | randomDurations(amount: amount) 136 | ).map({ RandomAnimation(delay: $0, duration: $1) }) 137 | } 138 | 139 | func performRandomAnimation() { 140 | performRandomAnimation(on: view!) 141 | } 142 | 143 | func newView() -> UIView { 144 | let view = UIView(frame: CGRect( 145 | x: .large, 146 | y: .large, 147 | width: .large, 148 | height: .large 149 | )) 150 | window.addSubview(view) 151 | return view 152 | } 153 | 154 | func performRandomAnimation(on view: UIView) { 155 | enum RandomAnimation: CaseIterable { 156 | case smallFrame 157 | case largeFrame 158 | case transformScale 159 | case transformTranslate 160 | case backgroundColor 161 | } 162 | 163 | switch RandomAnimation.allCases.randomElement()! { 164 | case .smallFrame: 165 | view.frame = CGRect(x: .small, y: .small, width: .medium, height: .medium) 166 | case .largeFrame: 167 | view.frame = CGRect(x: .small, y: .small, width: .large, height: .large) 168 | case .transformScale: 169 | view.transform = CGAffineTransform(scaleX: .tiny, y: .tiny) 170 | case .transformTranslate: 171 | view.transform = CGAffineTransform(translationX: .medium, y: .medium) 172 | case .backgroundColor: 173 | view.backgroundColor = UIColor(red: .color, green: .color, blue: .color, alpha: .color) 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Tests/AnimationPlannerTests/BaselineTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import AnimationPlanner 3 | 4 | final class BaselineTests: AnimationPlannerTests { 5 | func testUIViewAnimation() { 6 | runAnimationTest { completion, duration, _ in 7 | UIView.animate(withDuration: duration) { 8 | self.performRandomAnimation() 9 | } completion: { finished in 10 | completion(finished) 11 | } 12 | } 13 | } 14 | 15 | func testNoopUIViewAnimation() { 16 | XCTExpectFailure("Noop animations should immediately finish") 17 | runAnimationTest { completion, duration, _ in 18 | UIView.animate(withDuration: duration) { 19 | print("🤫 Do nothing") 20 | } completion: { finished in 21 | completion(finished) 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/AnimationPlannerTests/BuilderTests.swift: -------------------------------------------------------------------------------- 1 | import AnimationPlanner 2 | import Foundation 3 | import UIKit 4 | import XCTest 5 | 6 | class BuilderTests: AnimationPlannerTests { 7 | 8 | func testContainedAnimations() { 9 | let animation = Animate(duration: 1) { 10 | self.performRandomAnimation() 11 | } 12 | let spring = animation.spring(damping: 2) 13 | 14 | let simplerSpring = AnimateSpring(duration: 1, dampingRatio: 2) 15 | XCTAssertEqual(spring.dampingRatio, simplerSpring.dampingRatio) 16 | XCTAssertEqual(spring.duration, simplerSpring.duration) 17 | let springOptions = spring.options ?? [] 18 | XCTAssertFalse(springOptions.contains(.allowUserInteraction)) 19 | let editedOptions = spring.allowUserInteraction().options ?? [] 20 | XCTAssertTrue(editedOptions.contains(.allowUserInteraction)) 21 | 22 | let delay = spring.delayed(3) 23 | XCTAssertEqual(delay.duration, spring.duration + delay.delay) 24 | 25 | let options: UIView.AnimationOptions = .allowAnimatedContent 26 | let editedDelay = delay.options(options) 27 | let containedAnimation = editedDelay.animation.animation 28 | let springed = editedDelay.spring(damping: 4) 29 | 30 | XCTAssertEqual(editedDelay.options, containedAnimation.options) 31 | XCTAssertEqual(editedDelay.dampingRatio, spring.dampingRatio) 32 | XCTAssertNotEqual(springed.dampingRatio, spring.dampingRatio) 33 | XCTAssertEqual(springed.delay, delay.delay) 34 | 35 | let ridiculousAnimation = Animate(duration: 1) 36 | .delayed(2) 37 | .spring(damping: 3) 38 | .delayed(4) 39 | .spring(damping: 5) 40 | .delayed(6) 41 | .spring(damping: 7) 42 | XCTAssertEqual(ridiculousAnimation.delay, 6) 43 | XCTAssertEqual(ridiculousAnimation.duration, 1 + 6) 44 | XCTAssertEqual(ridiculousAnimation.delayed(6).delay, 6) 45 | XCTAssertEqual(ridiculousAnimation.delayed(6).duration, 1 + 6) 46 | XCTAssertEqual(ridiculousAnimation.dampingRatio, 7) 47 | } 48 | 49 | func testGroupDuration() { 50 | let group = Group { 51 | Animate(duration: 1) 52 | Animate(duration: 1) 53 | .spring(damping: 0.5) 54 | .delayed(0.5) 55 | Animate(duration: 1) 56 | .delayed(1) 57 | .spring(damping: 0.5) 58 | } 59 | XCTAssert(group.duration == 1 + 1) 60 | } 61 | 62 | func testSequenceDuration() { 63 | let waitStartingSequence = Sequence { 64 | Wait(1) 65 | Animate(duration: 1) 66 | } 67 | let waitEndingSequence = Sequence { 68 | Animate(duration: 1) 69 | Wait(1) 70 | } 71 | XCTAssertEqual(waitStartingSequence.duration, waitEndingSequence.duration) 72 | } 73 | 74 | func testGroupedSequenceDuration() { 75 | let animations = randomDelayedAnimations(amount: 2) 76 | let longestAnimation = animations.max { $0.totalDuration < $1.totalDuration}! 77 | let precision = durationPrecision * TimeInterval(animations.count) 78 | 79 | let waitStartingGroup = Group { 80 | for animation in animations { 81 | Sequence { 82 | Wait(animation.delay) 83 | Animate(duration: animation.duration) 84 | } 85 | } 86 | } 87 | 88 | let waitEndingGroup = Group { 89 | for animation in animations { 90 | Sequence { 91 | Animate(duration: animation.duration) 92 | Wait(animation.delay) 93 | } 94 | } 95 | } 96 | XCTAssertEqual(waitStartingGroup.duration, longestAnimation.totalDuration) 97 | XCTAssertEqual(waitStartingGroup.duration, waitEndingGroup.duration) 98 | 99 | runAnimationBuilderTest(duration: longestAnimation.totalDuration, precision: precision) { _, _ in 100 | AnimationPlanner.plan { 101 | waitStartingGroup 102 | } 103 | } 104 | 105 | runAnimationBuilderTest(duration: longestAnimation.totalDuration, precision: precision) { _, _ in 106 | AnimationPlanner.plan { 107 | waitEndingGroup 108 | } 109 | } 110 | } 111 | 112 | func testEmptyBuilder() { 113 | 114 | runAnimationBuilderTest(duration: 0) { _, _ in 115 | 116 | AnimationPlanner.plan { 117 | Extra { 118 | print("👋") 119 | } 120 | } 121 | 122 | } 123 | } 124 | 125 | func testBuilder() { 126 | let totalDuration: TimeInterval = 1 127 | let numberOfSteps: TimeInterval = 3 128 | let duration = totalDuration / numberOfSteps 129 | 130 | runAnimationBuilderTest(duration: totalDuration) { _, _ in 131 | 132 | AnimationPlanner.plan { 133 | Animate(duration: duration) { 134 | self.performRandomAnimation() 135 | } 136 | Wait(duration) 137 | Animate(duration: duration) { 138 | self.performRandomAnimation() 139 | } 140 | .spring(damping: 0.8) 141 | } 142 | 143 | } 144 | } 145 | 146 | func testBuilderModifiers() { 147 | let totalDuration: TimeInterval = 1 148 | let numberOfSteps: TimeInterval = 3 149 | let duration = totalDuration / numberOfSteps 150 | 151 | runAnimationBuilderTest(duration: totalDuration) { _, _ in 152 | 153 | AnimationPlanner.plan { 154 | Animate(duration: duration) 155 | .changes { 156 | self.performRandomAnimation() 157 | } 158 | .spring(damping: 0.8) 159 | Wait(duration) 160 | Animate(duration: duration) { 161 | self.performRandomAnimation() 162 | } 163 | .options(.allowAnimatedContent) 164 | } 165 | 166 | } 167 | } 168 | 169 | func testBuilderContainerModifiers() { 170 | let totalDuration: TimeInterval = 1 171 | let numberOfSteps: TimeInterval = 1 172 | let duration = totalDuration / numberOfSteps 173 | 174 | runAnimationBuilderTest(duration: totalDuration) { _, _ in 175 | 176 | AnimationPlanner.plan { 177 | Animate(duration: duration) { 178 | self.performRandomAnimation() 179 | } 180 | .spring(damping: 0.82) 181 | .options(.allowUserInteraction) 182 | } 183 | 184 | } 185 | } 186 | 187 | func testDelayedSpring() { 188 | let duration: TimeInterval = 0.5 189 | let delay: TimeInterval = 0.25 190 | let totalDuration = delay + duration 191 | runAnimationBuilderTest(duration: totalDuration) { _, _ in 192 | 193 | AnimationPlanner.group { 194 | Animate(duration: duration) { 195 | self.performRandomAnimation() 196 | } 197 | .spring(damping: 0.82) 198 | .delayed(delay) 199 | } 200 | 201 | } 202 | } 203 | 204 | func testSpringedDelay() { 205 | let duration: TimeInterval = 0.5 206 | let delay: TimeInterval = 0.25 207 | let totalDuration = delay + duration 208 | 209 | let animation = Animate(duration: duration) { 210 | self.performRandomAnimation() 211 | } 212 | .delayed(delay) 213 | .spring(damping: 0.82) 214 | 215 | XCTAssertEqual(animation.duration, totalDuration) 216 | 217 | runAnimationBuilderTest(duration: totalDuration) { _, _ in 218 | 219 | AnimationPlanner.group { 220 | Animate(duration: duration) { 221 | self.performRandomAnimation() 222 | } 223 | .delayed(delay) 224 | .spring(damping: 0.82) 225 | } 226 | 227 | } 228 | } 229 | 230 | func testGroupSequence() { 231 | let numberOfLoops: Int = 4 232 | let animations = randomDelayedAnimations(amount: numberOfLoops) 233 | let totalDuration: TimeInterval = animations.max { $0.totalDuration < $1.totalDuration }?.totalDuration ?? 0 234 | 235 | runAnimationBuilderTest(duration: totalDuration) { usedDuration, usedPrecision in 236 | AnimationPlanner.group { 237 | for animation in animations { 238 | Sequence { 239 | Wait(animation.delay) 240 | Animate(duration: animation.duration) { 241 | self.performRandomAnimation() 242 | } 243 | } 244 | } 245 | } 246 | 247 | } 248 | } 249 | 250 | func testDelayedGroupSequence() { 251 | let numberOfLoops: Int = 4 252 | let animations = randomDelayedAnimations(amount: numberOfLoops) 253 | let totalDuration: TimeInterval = animations.max { $0.totalDuration < $1.totalDuration }?.totalDuration ?? 0 254 | 255 | let delay = randomDuration 256 | let views = animations.map { _ in newView() } 257 | 258 | let precision = durationPrecision * TimeInterval(numberOfLoops) 259 | 260 | runAnimationBuilderTest(duration: delay + totalDuration, precision: precision) { _, _ in 261 | 262 | AnimationPlanner.plan { 263 | Wait(delay) 264 | Group { 265 | zip(views, animations).mapGroup { view, animation in 266 | Sequence { 267 | Wait(animation.delay) 268 | Animate(duration: animation.duration) { 269 | self.performRandomAnimation(on: view) 270 | } 271 | } 272 | } 273 | } 274 | } 275 | 276 | } 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /Tests/AnimationPlannerTests/ComplexAnimationTests.swift: -------------------------------------------------------------------------------- 1 | import AnimationPlanner 2 | import UIKit 3 | import XCTest 4 | 5 | class ComplexAnimationTest: AnimationPlannerTests { 6 | 7 | /// Creates a pretty complex animation with multiple groups each containing multiple sequences 8 | /// Groups can contain sequences that perform their animations in sequence, but each sequence 9 | /// is running at the same time in each group 10 | func testSequenceGroup() { 11 | 12 | let numberOfGroups = 2 13 | let numberOfSequences = 2 14 | let numberOfSteps = 2 15 | let groups: [[[TimeInterval]]] = (0..