├── .gitignore ├── LICENSE ├── README.md ├── assets ├── playground-simple.gif ├── scrubber-chained-1.gif ├── scrubber-simple-1.gif └── scrubber-simple-2.gif └── source └── Animation.playground ├── Pages ├── Keyframe example.xcplaygroundpage │ └── Contents.swift ├── Simple example.xcplaygroundpage │ └── Contents.swift └── Template.xcplaygroundpage │ └── Contents.swift ├── Sources └── ScrubContainerView.swift └── contents.xcplayground /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xcuserstate 23 | 24 | ## Obj-C/Swift specific 25 | *.hmap 26 | *.ipa 27 | *.dSYM.zip 28 | *.dSYM 29 | 30 | ## Playgrounds 31 | timeline.xctimeline 32 | playground.xcworkspace 33 | 34 | # Swift Package Manager 35 | # 36 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 37 | # Packages/ 38 | .build/ 39 | 40 | # CocoaPods 41 | # 42 | # We recommend against adding the Pods directory to your .gitignore. However 43 | # you should judge for yourself, the pros and cons are mentioned at: 44 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 45 | # 46 | # Pods/ 47 | 48 | # Carthage 49 | # 50 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 51 | # Carthage/Checkouts 52 | 53 | Carthage/Build 54 | 55 | # fastlane 56 | # 57 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 58 | # screenshots whenever they are needed. 59 | # For more information about the recommended setup visit: 60 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 61 | 62 | fastlane/report.xml 63 | fastlane/Preview.html 64 | fastlane/screenshots 65 | fastlane/test_output 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mathew Sanders 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Playground Animation Scrubber 🎚 2 | 3 | There are a lot of frameworks and tools for making animations and animated 4 | transitions for your iOS projects. 5 | 6 | If you're targeting iOS/tvOS 10.0+, then `UIViewPropertyAnimator` is probably 7 | powerful enough for your needs: It includes the standard easing curves, a nice 8 | implementation of spring physics, an easy way to define your own custom 9 | timing curve, and a scalable API that allows from simple single step 10 | interpolation, to chained, interruptible, and animations that can be modified on 11 | the fly. 12 | 13 | ## ScrubContainerView 14 | This project doesn't modify or build on top of `UIViewPropertyAnimator`, but 15 | instead provides `ScrubContainerView`, a simple UIView subclass, that makes it 16 | easier to create, explore, debug, and refine an animation in a playground. 17 | 18 | 1. Start by making a playground and importing `UIKit` and `PlaygroundSupport` 19 | 20 | ````Swift 21 | import UIKit 22 | import PlaygroundSupport 23 | ```` 24 | 25 | 2. Create a new `ScrubContainerView` and make that the playground's `liveView` 26 | (this presents the view in the playground's assistant editor panel. 27 | 28 | ````Swift 29 | let container = ScrubContainerView() 30 | PlaygroundPage.current.liveView = container 31 | ```` 32 | 33 | 3. Next, go ahead an set up the objects to be animated, adding them to the 34 | `stage` view of `ScrubContainerView` and setting their initial positions. 35 | 36 | ````Swift 37 | let square = UIView() 38 | container.stage.addSubview(square) 39 | square.center = container.stage.center 40 | square.transform = .identity 41 | square.bounds.size = CGSize(width: 150, height: 50) 42 | square.backgroundColor = .red 43 | ```` 44 | 45 | 4. Finally, assign `animator` with a closure that returns a 46 | `UIViewPropertyAnimator`. This defines the animations to perform. 47 | 48 | ````Swift 49 | container.animator = { 50 | 51 | // create the animator with the duration and timing curve 52 | // (in this case using a spring-physics) 53 | let animator = UIViewPropertyAnimator(duration: 2.0, dampingRatio: 0.5) 54 | 55 | // define the properties to animate 56 | animator.addAnimations { 57 | square.transform = CGAffineTransform(rotationAngle: CGFloat.pi/2) 58 | square.bounds.size = CGSize(width: 50, height: 150) 59 | square.backgroundColor = .blue 60 | } 61 | // return the animator 62 | return animator 63 | } 64 | ```` 65 | 66 | `ScrubContainerView` adds a `UISlider` that lets you scrub through the animation 67 | and look at it at any intermediate step. 68 | 69 | 70 | 71 | An option for step #3 is to wrap the expressions that define the stating state 72 | into the `startState` closure: 73 | 74 | ````Swift 75 | container.startState = { 76 | square.transform = .identity 77 | square.bounds.size = CGSize(width: 150, height: 50) 78 | square.backgroundColor = .red 79 | } 80 | ```` 81 | 82 | If this property is defined `ScrubContainerView` will add a button button that 83 | lets you to watch the animation perform with it's defined duration and timing 84 | curve: 85 | 86 | 87 | 88 | ### Initializers 89 | 90 | A `ScrubContainerView` can be initialized multiple ways. The default initializer 91 | creates a stage width a 300x300 pixel size. 92 | 93 | `ScrubContainerView.init(width: Double, height: Double)` allows a stage of any size. 94 | 95 | Convenience initializers `ScrubContainerView.init(device: Device)` and 96 | `ScrubContainerView.init(device: Device, orientation: Orientation)` allow for 97 | stages to be created for `Device.iPhoneSE`, `.iPhone`, and .`iPhonePlus` and 98 | orientations `.portrait` or `.landscape`. 99 | 100 | ### Examples 101 | 102 | The playground includes the example above, as well as a [slightly more complex 103 | example](https://github.com/mathewsanders/Scrubber/blob/master/source/Animation.playground/Pages/Keyframe%20example.xcplaygroundpage/Contents.swift) 104 | that includes a 4-step animation chained together using keyframes to show how 105 | more complex multi-step animations can be built, and a blank template ready to 106 | start writing a new animation from scratch. 107 | 108 | 109 | 110 | ## Roadmap 111 | - [x] UISlider to scrub animations 112 | - [x] Button to play animation with default timing curve and duration 113 | - [x] Initializers for different device sizes 114 | - [ ] Resume animation after scrubbing 115 | - [ ] Pause/resume animation 116 | - [ ] Scrub paused animation 117 | 118 | ## Requirements 119 | 120 | - Swift 3.0 121 | - iOS/tvOS 10.0+ 122 | 123 | ## Author 124 | 125 | Made with :heart: by [@permakittens](http://twitter.com/permakittens) 126 | 127 | ## Contributing 128 | 129 | Feedback, or contributions for bug fixing or improvements are welcome. Feel free to submit a pull request or open an issue. 130 | 131 | ## License 132 | 133 | MIT 134 | -------------------------------------------------------------------------------- /assets/playground-simple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathewsanders/Scrubber/71b148fe33528d9321f9de8417b0dc9e819dc40d/assets/playground-simple.gif -------------------------------------------------------------------------------- /assets/scrubber-chained-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathewsanders/Scrubber/71b148fe33528d9321f9de8417b0dc9e819dc40d/assets/scrubber-chained-1.gif -------------------------------------------------------------------------------- /assets/scrubber-simple-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathewsanders/Scrubber/71b148fe33528d9321f9de8417b0dc9e819dc40d/assets/scrubber-simple-1.gif -------------------------------------------------------------------------------- /assets/scrubber-simple-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathewsanders/Scrubber/71b148fe33528d9321f9de8417b0dc9e819dc40d/assets/scrubber-simple-2.gif -------------------------------------------------------------------------------- /source/Animation.playground/Pages/Keyframe example.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | //: [Previous](@previous) 2 | 3 | //: Keyframe animation example 4 | 5 | import UIKit 6 | import PlaygroundSupport 7 | 8 | let container = ScrubContainerView(device: .iPhoneSE) 9 | PlaygroundPage.current.liveView = container 10 | 11 | // some useful variables to help with animations 12 | let offstageLeft = CGAffineTransform(translationX: -100, y: 0) 13 | let offstageRight = CGAffineTransform(translationX: 100, y: 0) 14 | let capsuleFrameWide = CGRect(origin: .zero, size: CGSize(width: 80, height: 40)) 15 | let capsuleFrameNarrow = CGRect(origin: .zero, size: CGSize(width: 40, height: 40)) 16 | 17 | // create view to hold the dots ('capsule') 18 | let capsule = UIView(frame: capsuleFrameWide) 19 | capsule.backgroundColor = #colorLiteral(red: 0.921431005, green: 0.9214526415, blue: 0.9214410186, alpha: 1) 20 | capsule.layer.cornerRadius = 20 21 | capsule.clipsToBounds = true 22 | container.stage.addSubview(capsule) 23 | 24 | // create three dots and position relative to capsule 25 | let capsuleDots = [UIView(), UIView(), UIView()] 26 | 27 | capsuleDots.forEach({ dot in 28 | capsule.addSubview(dot) 29 | dot.center = capsule.center 30 | dot.bounds.size = CGSize(width: 10, height: 10) 31 | dot.layer.cornerRadius = 5 32 | dot.backgroundColor = #colorLiteral(red: 0.2196078449, green: 0.007843137719, blue: 0.8549019694, alpha: 1) 33 | }) 34 | 35 | capsuleDots[0].center = capsule.center.applying(CGAffineTransform(translationX: 20, y: 0)) 36 | capsuleDots[1].center = capsule.center 37 | capsuleDots[2].center = capsule.center.applying(CGAffineTransform(translationX: -20, y: 0)) 38 | 39 | capsule.center = container.stage.center 40 | 41 | // reset the dots and capsule for animation start 42 | container.startState = { 43 | 44 | capsuleDots.forEach({ dot in 45 | dot.transform = offstageLeft 46 | }) 47 | 48 | capsule.bounds = capsuleFrameNarrow 49 | } 50 | 51 | // define the four steps of the animation 52 | container.animator = { 53 | 54 | let animator = UIViewPropertyAnimator(duration: 1.5, curve: .easeInOut) 55 | 56 | animator.addAnimations { 57 | 58 | UIView.animateKeyframes(withDuration: 2, delay: 0, options: [.calculationModeLinear], animations: { 59 | 60 | // Step 1: make the capsule grow to large size 61 | UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.1) { 62 | capsule.bounds = capsuleFrameWide 63 | } 64 | 65 | // enumerate over dots so they are slightly staggered 66 | for (index, dot) in capsuleDots.enumerated() { 67 | let offsetDelay = TimeInterval(index) * 0.025 68 | 69 | // Step 2: move the dots to their default positions, and fade in 70 | UIView.addKeyframe(withRelativeStartTime: 0.05 + offsetDelay, relativeDuration: 0.2) { 71 | dot.transform = .identity 72 | dot.alpha = 1.0 73 | } 74 | 75 | // Step 3: fade out dots and translate to the right 76 | UIView.addKeyframe(withRelativeStartTime: 0.8 + offsetDelay, relativeDuration: 0.2) { 77 | 78 | //dot.alpha = 0.0 79 | dot.transform = offstageRight 80 | } 81 | } 82 | 83 | // Step 4: make capsure move to narrow width 84 | UIView.addKeyframe(withRelativeStartTime: 0.875, relativeDuration: 0.1) { 85 | capsule.bounds = capsuleFrameNarrow 86 | } 87 | }) 88 | } 89 | 90 | return animator 91 | } 92 | 93 | //: [Next](@next) 94 | -------------------------------------------------------------------------------- /source/Animation.playground/Pages/Simple example.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | //: Simple animation example 2 | 3 | import UIKit 4 | import PlaygroundSupport 5 | 6 | let container = ScrubContainerView() 7 | PlaygroundPage.current.liveView = container 8 | 9 | // set up views to animate 10 | let square = UIView() 11 | container.stage.addSubview(square) 12 | square.center = container.stage.center 13 | 14 | container.startState = { 15 | square.transform = .identity 16 | square.bounds.size = CGSize(width: 150, height: 50) 17 | square.backgroundColor = .red 18 | } 19 | 20 | // provide the container the animator to scrub 21 | container.animator = { 22 | 23 | let animator = UIViewPropertyAnimator(duration: 2.0, dampingRatio: 0.5) 24 | 25 | animator.addAnimations { 26 | square.transform = CGAffineTransform(rotationAngle: CGFloat.pi/2) 27 | square.bounds.size = CGSize(width: 50, height: 150) 28 | square.backgroundColor = .blue 29 | } 30 | 31 | return animator 32 | } 33 | -------------------------------------------------------------------------------- /source/Animation.playground/Pages/Template.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | //: Simple animation example 2 | 3 | import UIKit 4 | import PlaygroundSupport 5 | 6 | let container = ScrubContainerView() 7 | PlaygroundPage.current.liveView = container 8 | 9 | // create objects and add to `container.stage` 10 | 11 | // define the animation start/reset state 12 | container.startState = { 13 | 14 | } 15 | 16 | container.animator = { 17 | 18 | // set up the animator 19 | let animator = UIViewPropertyAnimator(duration: 1.0, curve: .easeInOut) 20 | 21 | // define the animations 22 | animator.addAnimations { 23 | 24 | } 25 | 26 | return animator 27 | } 28 | -------------------------------------------------------------------------------- /source/Animation.playground/Sources/ScrubContainerView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public class ScrubContainerView: UIView { 4 | 5 | private let scrubber: UISlider 6 | private let playButton: UIButton 7 | private let resetButton: UIButton 8 | private var scrubAnimator: UIViewPropertyAnimator? 9 | private var containerView = UIView() 10 | private let stack = UIStackView() 11 | 12 | public var stage: UIView { 13 | return containerView 14 | } 15 | 16 | /// The property animator to drive animations 17 | public var animator: (() -> UIViewPropertyAnimator)? { 18 | didSet { 19 | scrubAnimator = animator?() 20 | } 21 | } 22 | 23 | /// The start state for objects to be animated 24 | public var startState: (() -> ())? { 25 | didSet { 26 | guard let startState = startState else { return } 27 | 28 | startState() 29 | showButtons() 30 | } 31 | } 32 | 33 | public enum Device { 34 | case iPhone 35 | case iPhonePlus 36 | case iPhoneSE 37 | 38 | var portraitSize: CGSize { 39 | switch self { 40 | case .iPhoneSE: return CGSize(width: 320, height: 568) 41 | case .iPhone: return CGSize(width: 375, height: 667) 42 | case .iPhonePlus: return CGSize(width: 414, height: 736) 43 | } 44 | } 45 | 46 | func size(for orientation: Orientation) -> CGSize { 47 | switch orientation { 48 | case .portrait: return portraitSize 49 | case .landscape: return CGSize(width: portraitSize.height, height: portraitSize.width) 50 | } 51 | } 52 | } 53 | 54 | public enum Orientation { 55 | case portrait 56 | case landscape 57 | } 58 | 59 | public convenience init(width: Double, height: Double) { 60 | self.init(size: CGSize(width: width, height: height)) 61 | } 62 | 63 | public convenience init(device: Device, orientation: Orientation = .portrait) { 64 | self.init(size: device.size(for: orientation)) 65 | } 66 | 67 | public init(size: CGSize = CGSize(width: 300, height: 300)) { 68 | 69 | containerView.frame = CGRect(origin: .zero, size: size) 70 | containerView.backgroundColor = .white 71 | 72 | playButton = UIButton(type: .system) 73 | resetButton = UIButton(type: .system) 74 | scrubber = UISlider() 75 | 76 | super.init(frame: CGRect(origin: .zero, size: CGSize(width: size.width + 20, height: size.height + 60))) 77 | 78 | backgroundColor = UIColor.groupTableViewBackground 79 | containerView.layer.cornerRadius = 4 80 | addSubview(containerView) 81 | 82 | containerView.transform = CGAffineTransform(translationX: 10, y: 10) 83 | 84 | playButton.isHidden = true 85 | resetButton.isHidden = true 86 | 87 | stack.alignment = .fill 88 | stack.axis = .horizontal 89 | stack.distribution = .fill 90 | stack.spacing = 10 91 | stack.translatesAutoresizingMaskIntoConstraints = false 92 | addSubview(stack) 93 | 94 | stack.leftAnchor.constraint(equalTo: leftAnchor, constant: 10).isActive = true 95 | stack.rightAnchor.constraint(equalTo: rightAnchor, constant: -10).isActive = true 96 | stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0).isActive = true 97 | stack.heightAnchor.constraint(equalToConstant: 50).isActive = true 98 | 99 | stack.addArrangedSubview(playButton) 100 | stack.addArrangedSubview(scrubber) 101 | stack.addArrangedSubview(resetButton) 102 | 103 | 104 | scrubber.addTarget(self, action: #selector(ScrubContainerView.handleScrub), for: .valueChanged) 105 | } 106 | 107 | public override func addSubview(_ view: UIView) { 108 | if view == containerView || view == stack { 109 | super.addSubview(view) 110 | } 111 | else { 112 | print("adding", view, "to contaier") 113 | containerView.addSubview(view) 114 | } 115 | } 116 | 117 | private func showButtons() { 118 | 119 | playButton.setTitle("Play", for: .normal) 120 | resetButton.setTitle("Reset", for: .normal) 121 | 122 | playButton.addTarget(self, action: #selector(ScrubContainerView.handlePlay), for: .touchDown) 123 | resetButton.addTarget(self, action: #selector(ScrubContainerView.handleReset), for: .touchDown) 124 | 125 | playButton.isHidden = false 126 | resetButton.isHidden = false 127 | } 128 | 129 | func handlePlay() { 130 | 131 | guard let animator = animator?() else { return } 132 | 133 | animator.addAnimations { 134 | self.scrubber.setValue(1.0, animated: true) 135 | } 136 | 137 | animator.addCompletion({ _ in 138 | self.handleReset() 139 | }) 140 | 141 | playButton.isEnabled = false 142 | scrubber.isEnabled = false 143 | 144 | animator.startAnimation() 145 | } 146 | 147 | func handleReset() { 148 | 149 | scrubber.isEnabled = true 150 | playButton.isEnabled = true 151 | 152 | scrubAnimator?.stopAnimation(true) 153 | scrubAnimator = animator?() 154 | scrubber.setValue(0.0, animated: true) 155 | startState?() 156 | } 157 | 158 | func handleScrub() { 159 | playButton.isEnabled = false 160 | scrubAnimator?.fractionComplete = CGFloat(scrubber.value) 161 | } 162 | 163 | required public init?(coder aDecoder: NSCoder) { 164 | fatalError("init(coder:) has not been implemented") 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /source/Animation.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | --------------------------------------------------------------------------------