├── .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://swiftpackageindex.com/PimCoumans/AnimationPlanner)
2 | [](https://swiftpackageindex.com/PimCoumans/AnimationPlanner)
3 |
4 | 
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..