├── .gitignore ├── Fluid_Interactions.playgroundbook ├── Contents │ ├── Chapters │ │ └── Chapter1.playgroundchapter │ │ │ ├── Manifest.plist │ │ │ └── Pages │ │ │ ├── My Playground.playgroundpage │ │ │ ├── Manifest.plist │ │ │ └── main.swift │ │ │ └── Template.playgroundpage │ │ │ ├── Manifest.plist │ │ │ └── main.swift │ ├── Manifest.plist │ └── UserModules │ │ └── UserModule.playgroundmodule │ │ └── Sources │ │ └── SharedCode.swift └── Edits │ └── UserEdits.diffpack │ ├── Chapters │ └── Chapter1.playgroundchapter │ │ ├── Pages │ │ ├── 00DC7632-0CC9-4D6E-A489-85C24E1692E7.playgroundpage │ │ │ ├── UserManifest.plist │ │ │ └── main.swift.delta │ │ ├── 1A5ECE61-CC09-4F3E-B069-F428B3C59171.playgroundpage │ │ │ ├── UserManifest.plist │ │ │ └── main.swift.delta │ │ ├── 26B32787-233E-4BDE-87AD-6D7FE645E879.playgroundpage │ │ │ ├── UserManifest.plist │ │ │ └── main.swift.delta │ │ ├── 62637B3C-B0CD-47E8-87FE-A27F10EDC099.playgroundpage │ │ │ ├── UserManifest.plist │ │ │ └── main.swift.delta │ │ ├── 6A049FD3-14B6-49A6-89F9-8DAD65CF3D92.playgroundpage │ │ │ ├── UserManifest.plist │ │ │ └── main.swift.delta │ │ ├── 6D72675A-6BDB-4A44-8F44-C7F82CA6B5CD.playgroundpage │ │ │ ├── UserManifest.plist │ │ │ └── main.swift.delta │ │ └── My Playground.playgroundpage │ │ │ ├── UserManifest.plist │ │ │ └── main.swift.delta │ │ └── UserManifest.plist │ └── Manifest.plist ├── Fluid_Interactions_Mac.playground ├── Contents.swift ├── Pages │ ├── 1 - Simple Move.xcplaygroundpage │ │ ├── Contents.swift │ │ └── timeline.xctimeline │ ├── 2 - Center Correction.xcplaygroundpage │ │ └── Contents.swift │ ├── 3 - Bounce Back.xcplaygroundpage │ │ └── Contents.swift │ ├── 4 - Bounce Back Interruptible.xcplaygroundpage │ │ └── Contents.swift │ ├── 5 - Snap with Momentum.xcplaygroundpage │ │ └── Contents.swift │ ├── 6 - Snap with Projection.xcplaygroundpage │ │ └── Contents.swift │ └── 7 - Snap with Projection++.xcplaygroundpage │ │ └── Contents.swift ├── Sources │ ├── FluidTimingCurve.swift │ ├── Geometry.swift │ └── InstantPanGestureRecognizer.swift ├── contents.xcplayground └── timeline.xctimeline ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __MACOSX 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | *.xcworkspace 12 | !default.xcworkspace 13 | xcuserdata 14 | profile 15 | *.moved-aside 16 | DerivedData 17 | .idea/ 18 | Crashlytics.sh 19 | generatechangelog.sh 20 | Pods/ 21 | Carthage 22 | Provisioning 23 | Crashlytics.sh -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Contents/Chapters/Chapter1.playgroundchapter/Manifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Name 6 | My Playground 7 | TemplatePageFilename 8 | Template.playgroundpage 9 | InitialUserPages 10 | 11 | My Playground.playgroundpage 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Contents/Chapters/Chapter1.playgroundchapter/Pages/My Playground.playgroundpage/Manifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Name 6 | My Playground 7 | LiveViewEdgeToEdge 8 | 9 | LiveViewMode 10 | HiddenByDefault 11 | 12 | 13 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Contents/Chapters/Chapter1.playgroundchapter/Pages/My Playground.playgroundpage/main.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Contents/Chapters/Chapter1.playgroundchapter/Pages/Template.playgroundpage/Manifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Name 6 | Template Page 7 | LiveViewEdgeToEdge 8 | 9 | LiveViewMode 10 | HiddenByDefault 11 | 12 | 13 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Contents/Chapters/Chapter1.playgroundchapter/Pages/Template.playgroundpage/main.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Contents/Manifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Chapters 6 | 7 | Chapter1.playgroundchapter 8 | 9 | ContentIdentifier 10 | com.apple.playgrounds.blank 11 | ContentVersion 12 | 1.0 13 | DeploymentTarget 14 | ios-current 15 | DevelopmentRegion 16 | en 17 | SwiftVersion 18 | 5.1 19 | Version 20 | 7.0 21 | UserAutoImportedAuxiliaryModules 22 | 23 | UserModuleMode 24 | Full 25 | 26 | 27 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Contents/UserModules/UserModule.playgroundmodule/Sources/SharedCode.swift: -------------------------------------------------------------------------------- 1 | // Code inside modules can be shared between pages and other source files. 2 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/00DC7632-0CC9-4D6E-A489-85C24E1692E7.playgroundpage/UserManifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BasePageFilename 6 | Template.playgroundpage 7 | Name 8 | Snap with projection 9 | 10 | 11 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/00DC7632-0CC9-4D6E-A489-85C24E1692E7.playgroundpage/main.swift.delta: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BaseFile 6 | Chapters/Chapter1.playgroundchapter/Pages/Template.playgroundpage/main.swift 7 | Diffs 8 | 9 | 10 | ModifiedContent 11 | 12 | import UIKit 13 | import PlaygroundSupport 14 | 15 | final class DemoController: UIViewController { 16 | 17 | private lazy var movableView: UIView = { 18 | let v = UIView(frame: .zero) 19 | v.backgroundColor = .systemBlue 20 | v.layer.cornerRadius = 22 21 | v.layer.cornerCurve = .continuous 22 | return v 23 | }() 24 | 25 | private func makeDockingStation() -> UIView { 26 | let v = UIView() 27 | v.backgroundColor = .systemGray6 28 | v.layer.cornerRadius = 22 29 | v.layer.cornerCurve = .continuous 30 | return v 31 | } 32 | 33 | private lazy var dockingStationTopRight: UIView = { 34 | makeDockingStation() 35 | }() 36 | private lazy var dockingStationBottomRight: UIView = { 37 | makeDockingStation() 38 | }() 39 | private lazy var dockingStationBottomLeft: UIView = { 40 | makeDockingStation() 41 | }() 42 | 43 | private lazy var dockingStations: [UIView] = { 44 | [dockingStationTopRight, dockingStationBottomLeft, dockingStationBottomRight] 45 | }() 46 | 47 | override func loadView() { 48 | view = UIView() 49 | view.backgroundColor = .systemBackground 50 | 51 | view.addSubview(dockingStationTopRight) 52 | view.addSubview(dockingStationBottomRight) 53 | view.addSubview(dockingStationBottomLeft) 54 | view.addSubview(movableView) 55 | 56 | let moveGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 57 | movableView.addGestureRecognizer(moveGesture) 58 | } 59 | 60 | @objc private func reset() { 61 | movableView.frame = CGRect(x: view.bounds.width-150, y: 50, width: 100, height: 100) 62 | } 63 | 64 | override func viewDidAppear(_ animated: Bool) { 65 | super.viewDidAppear(animated) 66 | 67 | DispatchQueue.main.asyncAfter(deadline: .now()+1) { 68 | self.movableView.frame = CGRect(x: self.view.bounds.width-150, y: 50, width: 100, height: 100) 69 | self.dockingStationTopRight.frame = self.movableView.frame 70 | self.dockingStationBottomLeft.frame = CGRect(x: 50, y: self.view.bounds.height-150, width: 100, height: 100) 71 | self.dockingStationBottomRight.frame = CGRect(x: self.movableView.frame.origin.x, y: self.view.bounds.height-150, width: 100, height: 100) 72 | } 73 | } 74 | 75 | private var referencePoint: CGPoint = .zero 76 | 77 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 78 | let velocity = recognizer.velocity(in: view) 79 | 80 | switch recognizer.state { 81 | case .began: 82 | animator?.stopAnimation(true) 83 | 84 | referencePoint = recognizer.location(in: movableView) 85 | case .changed: 86 | let location = recognizer.location(in: view) 87 | movableView.origin = location - referencePoint 88 | case .ended, .cancelled: 89 | snapToClosestDockingStation(from: movableView.origin, with: velocity) 90 | default: 91 | break 92 | } 93 | } 94 | 95 | private var animator: UIViewPropertyAnimator? 96 | 97 | private func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat { 98 | return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate) 99 | } 100 | 101 | func projectedTargetPosition(with velocity: CGVector, from position: CGPoint) -> CGPoint { 102 | var velocity = velocity 103 | 104 | // We want to reduce movement along the secondary axis of the gesture. 105 | if velocity.dx != 0 || velocity.dy != 0 { 106 | let velocityInPrimaryDirection = max(abs(velocity.dx), abs(velocity.dy)) 107 | 108 | velocity.dx *= abs(velocity.dx / velocityInPrimaryDirection) 109 | velocity.dy *= abs(velocity.dy / velocityInPrimaryDirection) 110 | } 111 | 112 | let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue 113 | 114 | return CGPoint( 115 | x: position.x - 0.001 * velocity.dx / log(decelerationRate), 116 | y: position.y - 0.001 * velocity.dy / log(decelerationRate) 117 | ) 118 | } 119 | 120 | private func closestDockingStation(from currentOrigin: CGPoint, with velocity: CGPoint) -> CGPoint { 121 | let projectedPosition = projectedTargetPosition(with: CGVector(dx: velocity.x, dy: velocity.y), from: currentOrigin) 122 | return dockingStations.min(by: { projectedPosition.distance(to: $0.center) < projectedPosition.distance(to: $1.center) })!.origin 123 | } 124 | 125 | private func snapToClosestDockingStation(from currentOrigin: CGPoint, with velocity: CGPoint) { 126 | let closest = closestDockingStation(from: currentOrigin, with: velocity) 127 | snapToDockingStation(at: closest, with: velocity) 128 | } 129 | 130 | private func timingCurve(with velocity: CGVector) -> FluidTimingCurve { 131 | let damping: CGFloat = velocity.dy.isZero ? 100 : 30 132 | 133 | return FluidTimingCurve( 134 | velocity: velocity, 135 | stiffness: 400, 136 | damping: damping 137 | ) 138 | } 139 | 140 | private func snapToDockingStation(at position: CGPoint, with velocity: CGPoint) { 141 | // the 0.5 is to ensure there's always some distance for the gesture to work with 142 | let distanceX = (movableView.frame.origin.x - 0.5) - position.x 143 | let distanceY = (movableView.frame.origin.y - 0.5) - position.y 144 | 145 | let initialVelocityX = abs(velocity.x/distanceX) 146 | let initialVelocityY = abs(velocity.y/distanceY) 147 | 148 | let timing = timingCurve(with: CGVector(dx: initialVelocityX, dy: initialVelocityY)) 149 | 150 | animator = UIViewPropertyAnimator(duration: 2, timingParameters: timing) 151 | animator?.addAnimations { 152 | self.movableView.origin = position 153 | } 154 | animator?.startAnimation() 155 | } 156 | 157 | } 158 | 159 | PlaygroundPage.current.liveView = DemoController() 160 | 161 | ModifiedRange 162 | {1, 5688} 163 | OriginalContent 164 | 165 | OriginalRange 166 | {1, 0} 167 | 168 | 169 | FormatVersion 170 | 2 171 | 172 | 173 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/1A5ECE61-CC09-4F3E-B069-F428B3C59171.playgroundpage/UserManifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BasePageFilename 6 | Template.playgroundpage 7 | Name 8 | Snap with momentum 9 | 10 | 11 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/1A5ECE61-CC09-4F3E-B069-F428B3C59171.playgroundpage/main.swift.delta: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BaseFile 6 | Chapters/Chapter1.playgroundchapter/Pages/Template.playgroundpage/main.swift 7 | Diffs 8 | 9 | 10 | ModifiedContent 11 | 12 | import UIKit 13 | import PlaygroundSupport 14 | 15 | final class DemoController: UIViewController { 16 | 17 | private lazy var momentumLabel: UILabel = { 18 | let l = UILabel() 19 | l.text = "Momentum" 20 | l.textColor = .secondaryLabel 21 | return l 22 | }() 23 | 24 | private lazy var momentumSwitch: UISwitch = { 25 | let s = UISwitch() 26 | s.isOn = false 27 | return s 28 | }() 29 | 30 | private lazy var momentumStack: UIStackView = { 31 | let v = UIStackView(arrangedSubviews: [self.momentumLabel, self.momentumSwitch]) 32 | v.axis = .horizontal 33 | v.spacing = 8 34 | v.translatesAutoresizingMaskIntoConstraints = false 35 | return v 36 | }() 37 | 38 | private lazy var movableView: UIView = { 39 | let v = UIView(frame: .zero) 40 | v.backgroundColor = .systemBlue 41 | v.layer.cornerRadius = 22 42 | v.layer.cornerCurve = .continuous 43 | return v 44 | }() 45 | 46 | private lazy var dockingStation: UIView = { 47 | let v = UIView(frame: CGRect(x: 50, y: 400, width: 100, height: 100)) 48 | v.backgroundColor = .systemGray6 49 | v.layer.cornerRadius = 22 50 | v.layer.cornerCurve = .continuous 51 | return v 52 | }() 53 | 54 | override func loadView() { 55 | view = UIView() 56 | view.backgroundColor = .systemBackground 57 | 58 | view.addSubview(dockingStation) 59 | view.addSubview(movableView) 60 | view.addSubview(momentumStack) 61 | 62 | NSLayoutConstraint.activate([ 63 | momentumStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), 64 | momentumStack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16), 65 | ]) 66 | 67 | let moveGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 68 | movableView.addGestureRecognizer(moveGesture) 69 | 70 | let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(reset)) 71 | doubleTapGesture.numberOfTapsRequired = 2 72 | view.addGestureRecognizer(doubleTapGesture) 73 | } 74 | 75 | @objc private func reset() { 76 | movableView.frame = CGRect(x: view.bounds.width-150, y: 50, width: 100, height: 100) 77 | } 78 | 79 | override func viewDidAppear(_ animated: Bool) { 80 | super.viewDidAppear(animated) 81 | 82 | DispatchQueue.main.asyncAfter(deadline: .now()+1) { 83 | self.movableView.frame = CGRect(x: self.view.bounds.width-150, y: 50, width: 100, height: 100) 84 | self.dockingStation.frame = CGRect(x: 50, y: self.view.bounds.height-150, width: 100, height: 100) 85 | } 86 | } 87 | 88 | private var referencePoint: CGPoint = .zero 89 | 90 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 91 | let velocity = recognizer.velocity(in: view) 92 | 93 | switch recognizer.state { 94 | case .began: 95 | animator?.stopAnimation(true) 96 | 97 | referencePoint = recognizer.location(in: movableView) 98 | case .changed: 99 | let location = recognizer.location(in: view) 100 | movableView.origin = location - referencePoint 101 | case .ended, .cancelled: 102 | snapToDockingStation(with: velocity) 103 | default: 104 | break 105 | } 106 | } 107 | 108 | private var animator: UIViewPropertyAnimator? 109 | 110 | private func timingCurve(with velocity: CGFloat) -> FluidTimingCurve { 111 | let damping: CGFloat = velocity.isZero ? 100 : 30 112 | 113 | return FluidTimingCurve( 114 | velocity: CGVector(dx: velocity, dy: velocity), 115 | stiffness: 400, 116 | damping: damping 117 | ) 118 | } 119 | 120 | private func snapToDockingStation(with velocity: CGPoint) { 121 | // the 0.5 is to ensure there's always some distance for the gesture to work with 122 | let distanceY = (movableView.frame.origin.y - 0.5) - dockingStation.frame.origin.y 123 | 124 | let effectiveVelocity = velocity.y.isInfinite || velocity.y.isNaN ? 2000 : velocity.y 125 | 126 | let initialVelocityY = distanceY.isZero ? 0 : effectiveVelocity/distanceY * -1 127 | 128 | let timing: FluidTimingCurve 129 | 130 | if momentumSwitch.isOn { 131 | timing = timingCurve(with: initialVelocityY) 132 | } else { 133 | timing = FluidTimingCurve(velocity: CGVector(dx: 1, dy: 1), stiffness: 400, damping: 30) 134 | } 135 | 136 | animator = UIViewPropertyAnimator(duration: 2, timingParameters: timing) 137 | animator?.addAnimations { 138 | self.movableView.origin = self.dockingStation.origin 139 | } 140 | animator?.startAnimation() 141 | } 142 | 143 | } 144 | 145 | PlaygroundPage.current.liveView = DemoController() 146 | 147 | ModifiedRange 148 | {1, 4587} 149 | OriginalContent 150 | 151 | OriginalRange 152 | {1, 0} 153 | 154 | 155 | FormatVersion 156 | 2 157 | 158 | 159 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/26B32787-233E-4BDE-87AD-6D7FE645E879.playgroundpage/UserManifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BasePageFilename 6 | Template.playgroundpage 7 | Name 8 | Center Correction 9 | 10 | 11 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/26B32787-233E-4BDE-87AD-6D7FE645E879.playgroundpage/main.swift.delta: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BaseFile 6 | Chapters/Chapter1.playgroundchapter/Pages/Template.playgroundpage/main.swift 7 | Diffs 8 | 9 | 10 | ModifiedContent 11 | import UIKit 12 | import PlaygroundSupport 13 | 14 | final class SimpleMoveController: UIViewController { 15 | 16 | private lazy var movableView: UIView = { 17 | let v = UIView(frame: CGRect(x: 50, y: 50, width: 100, height: 100)) 18 | v.backgroundColor = .systemBlue 19 | v.layer.cornerRadius = 22 20 | v.layer.cornerCurve = .continuous 21 | return v 22 | }() 23 | 24 | override func loadView() { 25 | view = UIView() 26 | view.backgroundColor = .systemBackground 27 | 28 | view.addSubview(movableView) 29 | 30 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 31 | movableView.addGestureRecognizer(gesture) 32 | } 33 | 34 | private var referencePoint: CGPoint = .zero 35 | 36 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 37 | switch recognizer.state { 38 | case .began: 39 | referencePoint = recognizer.location(in: movableView) 40 | case .changed: 41 | let location = recognizer.location(in: view) 42 | var f = movableView.frame 43 | f.origin = location - referencePoint 44 | movableView.frame = f 45 | default: 46 | break 47 | } 48 | } 49 | 50 | } 51 | 52 | PlaygroundPage.current.liveView = SimpleMoveController() 53 | 54 | ModifiedRange 55 | {1, 1274} 56 | OriginalContent 57 | 58 | OriginalRange 59 | {1, 0} 60 | 61 | 62 | FormatVersion 63 | 2 64 | 65 | 66 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/62637B3C-B0CD-47E8-87FE-A27F10EDC099.playgroundpage/UserManifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BasePageFilename 6 | Template.playgroundpage 7 | Name 8 | Bounce Back 9 | 10 | 11 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/62637B3C-B0CD-47E8-87FE-A27F10EDC099.playgroundpage/main.swift.delta: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BaseFile 6 | Chapters/Chapter1.playgroundchapter/Pages/Template.playgroundpage/main.swift 7 | Diffs 8 | 9 | 10 | ModifiedContent 11 | 12 | import UIKit 13 | import PlaygroundSupport 14 | 15 | final class DemoController: UIViewController { 16 | 17 | private let initialPosition = CGPoint(x: 50, y: 50) 18 | 19 | private lazy var movableView: UIView = { 20 | let v = UIView(frame: CGRect(origin: initialPosition, size: CGSize(width: 100, height: 100))) 21 | v.backgroundColor = .systemBlue 22 | v.layer.cornerRadius = 22 23 | v.layer.cornerCurve = .continuous 24 | return v 25 | }() 26 | 27 | override func loadView() { 28 | view = UIView() 29 | view.backgroundColor = .systemBackground 30 | 31 | view.addSubview(movableView) 32 | 33 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 34 | movableView.addGestureRecognizer(gesture) 35 | } 36 | 37 | private var referencePoint: CGPoint = .zero 38 | 39 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 40 | switch recognizer.state { 41 | case .began: 42 | referencePoint = recognizer.location(in: movableView) 43 | case .changed: 44 | let location = recognizer.location(in: view) 45 | var f = movableView.frame 46 | f.origin = location - referencePoint 47 | movableView.frame = f 48 | case .ended, .cancelled: 49 | snapBack() 50 | default: 51 | break 52 | } 53 | } 54 | 55 | private func snapBack() { 56 | UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 1, options: [.allowUserInteraction, .beginFromCurrentState, .allowAnimatedContent], animations: { 57 | var f = self.movableView.frame 58 | f.origin = self.initialPosition 59 | self.movableView.frame = f 60 | }, completion: nil) 61 | } 62 | 63 | } 64 | 65 | PlaygroundPage.current.liveView = DemoController() 66 | 67 | ModifiedRange 68 | {1, 1767} 69 | OriginalContent 70 | 71 | OriginalRange 72 | {1, 0} 73 | 74 | 75 | FormatVersion 76 | 2 77 | 78 | 79 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/6A049FD3-14B6-49A6-89F9-8DAD65CF3D92.playgroundpage/UserManifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BasePageFilename 6 | Template.playgroundpage 7 | Name 8 | Bounce Back Interruptible 9 | 10 | 11 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/6A049FD3-14B6-49A6-89F9-8DAD65CF3D92.playgroundpage/main.swift.delta: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BaseFile 6 | Chapters/Chapter1.playgroundchapter/Pages/Template.playgroundpage/main.swift 7 | Diffs 8 | 9 | 10 | ModifiedContent 11 | 12 | 13 | import UIKit 14 | import PlaygroundSupport 15 | 16 | final class DemoController: UIViewController { 17 | 18 | private let initialPosition = CGPoint(x: 50, y: 50) 19 | 20 | private lazy var movableView: UIView = { 21 | let v = UIView(frame: CGRect(origin: initialPosition, size: CGSize(width: 100, height: 100))) 22 | v.backgroundColor = .systemBlue 23 | v.layer.cornerRadius = 22 24 | v.layer.cornerCurve = .continuous 25 | return v 26 | }() 27 | 28 | override func loadView() { 29 | view = UIView() 30 | view.backgroundColor = .systemBackground 31 | 32 | view.addSubview(movableView) 33 | 34 | let gesture = InstantPanGestureRecognizer(target: self, action: #selector(handlePan)) 35 | view.addGestureRecognizer(gesture) 36 | } 37 | 38 | private var referencePoint: CGPoint = .zero 39 | 40 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 41 | let location = recognizer.location(in: view) 42 | 43 | switch recognizer.state { 44 | case .began: 45 | animator?.stopAnimation(true) 46 | 47 | movableView.origin = location 48 | case .changed: 49 | movableView.origin = location 50 | case .ended, .cancelled: 51 | snapBack() 52 | default: 53 | break 54 | } 55 | } 56 | 57 | private var animator: UIViewPropertyAnimator? 58 | 59 | private func snapBack() { 60 | animator = UIViewPropertyAnimator(duration: 5, curve: .easeInOut, animations: nil) 61 | animator?.isUserInteractionEnabled = true 62 | animator?.isInterruptible = true 63 | animator?.addAnimations { 64 | self.movableView.origin = self.initialPosition 65 | } 66 | animator?.startAnimation() 67 | } 68 | 69 | } 70 | 71 | PlaygroundPage.current.liveView = DemoController() 72 | 73 | ModifiedRange 74 | {1, 1741} 75 | OriginalContent 76 | 77 | OriginalRange 78 | {1, 0} 79 | 80 | 81 | FormatVersion 82 | 2 83 | 84 | 85 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/6D72675A-6BDB-4A44-8F44-C7F82CA6B5CD.playgroundpage/UserManifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BasePageFilename 6 | Template.playgroundpage 7 | Name 8 | Snap with projection++ 9 | 10 | 11 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/6D72675A-6BDB-4A44-8F44-C7F82CA6B5CD.playgroundpage/main.swift.delta: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BaseFile 6 | Chapters/Chapter1.playgroundchapter/Pages/Template.playgroundpage/main.swift 7 | Diffs 8 | 9 | 10 | ModifiedContent 11 | import UIKit 12 | import PlaygroundSupport 13 | 14 | final class DemoController: UIViewController { 15 | 16 | private lazy var movableView: UIView = { 17 | let v = UIView(frame: .zero) 18 | v.backgroundColor = .systemBlue 19 | v.layer.cornerRadius = 22 20 | v.layer.cornerCurve = .continuous 21 | return v 22 | }() 23 | 24 | private func makeDockingStation() -> UIView { 25 | let v = UIView() 26 | v.layer.borderColor = UIColor.systemBlue.cgColor 27 | v.layer.borderWidth = 1 28 | v.layer.cornerRadius = 22 29 | v.layer.cornerCurve = .continuous 30 | return v 31 | } 32 | 33 | private lazy var dockingStationTopRight: UIView = { 34 | makeDockingStation() 35 | }() 36 | private lazy var dockingStationBottomRight: UIView = { 37 | makeDockingStation() 38 | }() 39 | private lazy var dockingStationBottomLeft: UIView = { 40 | makeDockingStation() 41 | }() 42 | 43 | private lazy var dockingStations: [UIView] = { 44 | [dockingStationTopRight, dockingStationBottomLeft, dockingStationBottomRight] 45 | }() 46 | 47 | override func loadView() { 48 | view = UIView() 49 | view.backgroundColor = .systemBackground 50 | 51 | dockingStations.forEach(view.addSubview) 52 | 53 | view.addSubview(movableView) 54 | 55 | let moveGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 56 | movableView.addGestureRecognizer(moveGesture) 57 | } 58 | 59 | override func viewDidAppear(_ animated: Bool) { 60 | super.viewDidAppear(animated) 61 | 62 | DispatchQueue.main.asyncAfter(deadline: .now()+1) { 63 | self.setupViews() 64 | } 65 | } 66 | 67 | private func setupViews() { 68 | movableView.frame = CGRect(x: view.bounds.width-150, y: 50, width: 100, height: 100) 69 | dockingStationTopRight.frame = movableView.frame 70 | dockingStationBottomLeft.frame = CGRect(x: 50, y: view.bounds.height-150, width: 100, height: 100) 71 | dockingStationBottomRight.frame = CGRect(x: movableView.frame.origin.x, y: view.bounds.height-150, width: 100, height: 100) 72 | } 73 | 74 | private var referencePoint: CGPoint = .zero 75 | 76 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 77 | let velocity = recognizer.velocity(in: view) 78 | 79 | switch recognizer.state { 80 | case .began: 81 | animator?.stopAnimation(true) 82 | 83 | referencePoint = recognizer.location(in: movableView) 84 | case .changed: 85 | let location = recognizer.location(in: view) 86 | movableView.origin = location - referencePoint 87 | case .ended, .cancelled: 88 | snapToClosestDockingStation(from: movableView.origin, with: velocity) 89 | default: 90 | break 91 | } 92 | } 93 | 94 | private func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat { 95 | return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate) 96 | } 97 | 98 | func projectedTargetPosition(with velocity: CGVector, from position: CGPoint) -> CGPoint { 99 | var velocity = velocity 100 | 101 | // Make sure the dominant axis has more weight when projecting the final position 102 | if velocity.dx != 0 || velocity.dy != 0 { 103 | let velocityInPrimaryDirection = max(abs(velocity.dx), abs(velocity.dy)) 104 | 105 | velocity.dx *= abs(velocity.dx / velocityInPrimaryDirection) 106 | velocity.dy *= abs(velocity.dy / velocityInPrimaryDirection) 107 | } 108 | 109 | let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue 110 | 111 | return CGPoint( 112 | x: position.x - 0.001 * velocity.dx / log(decelerationRate), 113 | y: position.y - 0.001 * velocity.dy / log(decelerationRate) 114 | ) 115 | } 116 | 117 | private func closestDockingStation(from currentOrigin: CGPoint, with velocity: CGPoint) -> CGPoint { 118 | let projectedPosition = projectedTargetPosition(with: CGVector(dx: velocity.x, dy: velocity.y), from: currentOrigin) 119 | return dockingStations.min(by: { projectedPosition.distance(to: $0.center) < projectedPosition.distance(to: $1.center) })!.origin 120 | } 121 | 122 | private func snapToClosestDockingStation(from currentOrigin: CGPoint, with velocity: CGPoint) { 123 | let closest = closestDockingStation(from: currentOrigin, with: velocity) 124 | snapToDockingStation(at: closest, with: velocity) 125 | } 126 | 127 | private func timingCurve(with velocity: CGVector) -> FluidTimingCurve { 128 | let damping: CGFloat = velocity.dy.isZero ? 100 : 30 129 | 130 | return FluidTimingCurve( 131 | velocity: velocity, 132 | stiffness: 400, 133 | damping: damping 134 | ) 135 | } 136 | 137 | private var animator: UIViewPropertyAnimator? 138 | private var bounceAnimator: UIViewPropertyAnimator? 139 | 140 | private func snapToDockingStation(at position: CGPoint, with velocity: CGPoint) { 141 | // the 0.5 is to ensure there's always some distance for the gesture to work with 142 | let distanceX = (movableView.frame.origin.x - 0.5) - position.x 143 | let distanceY = (movableView.frame.origin.y - 0.5) - position.y 144 | 145 | var initialVelocityX = abs(velocity.x/distanceX) 146 | var initialVelocityY = abs(velocity.y/distanceY) 147 | 148 | if initialVelocityX.isInfinite || initialVelocityX.isNaN { initialVelocityX = 1 } 149 | if initialVelocityY.isInfinite || initialVelocityY.isNaN { initialVelocityY = 1 } 150 | 151 | let timing = timingCurve(with: CGVector(dx: initialVelocityX, dy: initialVelocityY)) 152 | 153 | animator = UIViewPropertyAnimator(duration: 0, timingParameters: timing) 154 | bounceAnimator = UIViewPropertyAnimator(duration: animator!.duration, curve: .easeOut, animations: nil) 155 | 156 | animator?.addAnimations { 157 | self.movableView.origin = position 158 | } 159 | 160 | bounceAnimator?.addAnimations({ 161 | self.movableView.layer.transform = CATransform3DMakeScale(0.6, 0.6, 1) 162 | }, delayFactor: 0) 163 | bounceAnimator?.addAnimations({ 164 | self.movableView.layer.transform = CATransform3DIdentity 165 | }, delayFactor: 0.24) 166 | 167 | animator?.startAnimation() 168 | bounceAnimator?.startAnimation() 169 | } 170 | 171 | } 172 | 173 | PlaygroundPage.current.liveView = DemoController() 174 | 175 | ModifiedRange 176 | {0, 6258} 177 | OriginalContent 178 | 179 | 180 | OriginalRange 181 | {0, 1} 182 | 183 | 184 | FormatVersion 185 | 2 186 | 187 | 188 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/My Playground.playgroundpage/UserManifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Name 6 | Simple Move 7 | 8 | 9 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/Pages/My Playground.playgroundpage/main.swift.delta: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Diffs 6 | 7 | 8 | ModifiedContent 9 | import UIKit 10 | import PlaygroundSupport 11 | 12 | final class SimpleMoveController: UIViewController { 13 | 14 | private lazy var movableView: UIView = { 15 | let v = UIView(frame: CGRect(x: 50, y: 50, width: 100, height: 100)) 16 | v.backgroundColor = .systemBlue 17 | v.layer.cornerRadius = 22 18 | v.layer.cornerCurve = .continuous 19 | return v 20 | }() 21 | 22 | override func loadView() { 23 | view = UIView() 24 | view.backgroundColor = .systemBackground 25 | 26 | view.addSubview(movableView) 27 | 28 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 29 | movableView.addGestureRecognizer(gesture) 30 | } 31 | 32 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 33 | switch recognizer.state { 34 | case .changed: 35 | let location = recognizer.location(in: view) 36 | var f = movableView.frame 37 | f.origin = location 38 | movableView.frame = f 39 | default: 40 | break 41 | } 42 | } 43 | 44 | } 45 | 46 | PlaygroundPage.current.liveView = SimpleMoveController() 47 | 48 | ModifiedRange 49 | {0, 1109} 50 | OriginalContent 51 | 52 | 53 | OriginalRange 54 | {0, 1} 55 | 56 | 57 | FormatVersion 58 | 2 59 | 60 | 61 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Chapters/Chapter1.playgroundchapter/UserManifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UserPageFilenames 6 | 7 | My Playground.playgroundpage 8 | 26B32787-233E-4BDE-87AD-6D7FE645E879.playgroundpage 9 | 62637B3C-B0CD-47E8-87FE-A27F10EDC099.playgroundpage 10 | 6A049FD3-14B6-49A6-89F9-8DAD65CF3D92.playgroundpage 11 | 1A5ECE61-CC09-4F3E-B069-F428B3C59171.playgroundpage 12 | 00DC7632-0CC9-4D6E-A489-85C24E1692E7.playgroundpage 13 | 6D72675A-6BDB-4A44-8F44-C7F82CA6B5CD.playgroundpage 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Fluid_Interactions.playgroundbook/Edits/UserEdits.diffpack/Manifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BaseContentIdentifier 6 | com.apple.playgrounds.blank 7 | BaseContentVersion 8 | 1.0 9 | ContentType 10 | PlaygroundBookUserEdit 11 | Diffs 12 | 13 | Chapters/Chapter1.playgroundchapter/Pages/00DC7632-0CC9-4D6E-A489-85C24E1692E7.playgroundpage/UserManifest.plist 14 | Added 15 | Chapters/Chapter1.playgroundchapter/Pages/00DC7632-0CC9-4D6E-A489-85C24E1692E7.playgroundpage/main.swift 16 | Modified 17 | Chapters/Chapter1.playgroundchapter/Pages/1A5ECE61-CC09-4F3E-B069-F428B3C59171.playgroundpage/UserManifest.plist 18 | Added 19 | Chapters/Chapter1.playgroundchapter/Pages/1A5ECE61-CC09-4F3E-B069-F428B3C59171.playgroundpage/main.swift 20 | Modified 21 | Chapters/Chapter1.playgroundchapter/Pages/26B32787-233E-4BDE-87AD-6D7FE645E879.playgroundpage/UserManifest.plist 22 | Added 23 | Chapters/Chapter1.playgroundchapter/Pages/26B32787-233E-4BDE-87AD-6D7FE645E879.playgroundpage/main.swift 24 | Modified 25 | Chapters/Chapter1.playgroundchapter/Pages/62637B3C-B0CD-47E8-87FE-A27F10EDC099.playgroundpage/UserManifest.plist 26 | Added 27 | Chapters/Chapter1.playgroundchapter/Pages/62637B3C-B0CD-47E8-87FE-A27F10EDC099.playgroundpage/main.swift 28 | Modified 29 | Chapters/Chapter1.playgroundchapter/Pages/6A049FD3-14B6-49A6-89F9-8DAD65CF3D92.playgroundpage/UserManifest.plist 30 | Added 31 | Chapters/Chapter1.playgroundchapter/Pages/6A049FD3-14B6-49A6-89F9-8DAD65CF3D92.playgroundpage/main.swift 32 | Modified 33 | Chapters/Chapter1.playgroundchapter/Pages/6D72675A-6BDB-4A44-8F44-C7F82CA6B5CD.playgroundpage/UserManifest.plist 34 | Added 35 | Chapters/Chapter1.playgroundchapter/Pages/6D72675A-6BDB-4A44-8F44-C7F82CA6B5CD.playgroundpage/main.swift 36 | Modified 37 | Chapters/Chapter1.playgroundchapter/Pages/My Playground.playgroundpage/UserManifest.plist 38 | Added 39 | Chapters/Chapter1.playgroundchapter/Pages/My Playground.playgroundpage/main.swift 40 | Modified 41 | Chapters/Chapter1.playgroundchapter/UserManifest.plist 42 | Added 43 | UserModules/UserModule.playgroundmodule/Sources/FluidTimingCurve.swift 44 | Added 45 | UserModules/UserModule.playgroundmodule/Sources/Geometry.swift 46 | Added 47 | UserModules/UserModule.playgroundmodule/Sources/InstantPanGestureRecognizer.swift 48 | Added 49 | UserModules/UserModule.playgroundmodule/Sources/SharedCode.swift 50 | Removed 51 | 52 | FormatVersion 53 | 2 54 | UpdatedContentVersion 55 | 1.0 56 | 57 | 58 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PlaygroundSupport 3 | 4 | final class SimpleMoveController: UIViewController { 5 | 6 | private lazy var movableView: UIView = { 7 | let v = UIView(frame: CGRect(x: 50, y: 50, width: 100, height: 100)) 8 | v.backgroundColor = .systemBlue 9 | v.layer.cornerRadius = 22 10 | v.layer.cornerCurve = .continuous 11 | return v 12 | }() 13 | 14 | override func loadView() { 15 | view = UIView() 16 | view.backgroundColor = .systemBackground 17 | 18 | view.addSubview(movableView) 19 | 20 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 21 | movableView.addGestureRecognizer(gesture) 22 | } 23 | 24 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 25 | switch recognizer.state { 26 | case .changed: 27 | let location = recognizer.location(in: view) 28 | var f = movableView.frame 29 | f.origin = location 30 | movableView.frame = f 31 | default: 32 | break 33 | } 34 | } 35 | 36 | } 37 | 38 | PlaygroundPage.current.liveView = SimpleMoveController() 39 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Pages/1 - Simple Move.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PlaygroundSupport 3 | 4 | final class SimpleMoveController: UIViewController { 5 | 6 | private lazy var movableView: UIView = { 7 | let v = UIView(frame: CGRect(x: 50, y: 50, width: 100, height: 100)) 8 | v.backgroundColor = .systemBlue 9 | v.layer.cornerRadius = 22 10 | v.layer.cornerCurve = .continuous 11 | return v 12 | }() 13 | 14 | override func loadView() { 15 | view = UIView() 16 | view.backgroundColor = .systemBackground 17 | 18 | view.addSubview(movableView) 19 | 20 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 21 | movableView.addGestureRecognizer(gesture) 22 | } 23 | 24 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 25 | switch recognizer.state { 26 | case .changed: 27 | let location = recognizer.location(in: view) 28 | var f = movableView.frame 29 | f.origin = location 30 | movableView.frame = f 31 | default: 32 | break 33 | } 34 | } 35 | 36 | } 37 | 38 | PlaygroundPage.current.liveView = SimpleMoveController() 39 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Pages/1 - Simple Move.xcplaygroundpage/timeline.xctimeline: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Pages/2 - Center Correction.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PlaygroundSupport 3 | 4 | final class SimpleMoveController: UIViewController { 5 | 6 | private lazy var movableView: UIView = { 7 | let v = UIView(frame: CGRect(x: 50, y: 50, width: 100, height: 100)) 8 | v.backgroundColor = .systemBlue 9 | v.layer.cornerRadius = 22 10 | v.layer.cornerCurve = .continuous 11 | return v 12 | }() 13 | 14 | override func loadView() { 15 | view = UIView() 16 | view.backgroundColor = .systemBackground 17 | 18 | view.addSubview(movableView) 19 | 20 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 21 | movableView.addGestureRecognizer(gesture) 22 | } 23 | 24 | private var referencePoint: CGPoint = .zero 25 | 26 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 27 | switch recognizer.state { 28 | case .began: 29 | referencePoint = recognizer.location(in: movableView) 30 | case .changed: 31 | let location = recognizer.location(in: view) 32 | var f = movableView.frame 33 | f.origin = location - referencePoint 34 | movableView.frame = f 35 | default: 36 | break 37 | } 38 | } 39 | 40 | } 41 | 42 | PlaygroundPage.current.liveView = SimpleMoveController() 43 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Pages/3 - Bounce Back.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PlaygroundSupport 3 | 4 | final class DemoController: UIViewController { 5 | 6 | private let initialPosition = CGPoint(x: 50, y: 50) 7 | 8 | private lazy var movableView: UIView = { 9 | let v = UIView(frame: CGRect(origin: initialPosition, size: CGSize(width: 100, height: 100))) 10 | v.backgroundColor = .systemBlue 11 | v.layer.cornerRadius = 22 12 | v.layer.cornerCurve = .continuous 13 | return v 14 | }() 15 | 16 | override func loadView() { 17 | view = UIView() 18 | view.backgroundColor = .systemBackground 19 | 20 | view.addSubview(movableView) 21 | 22 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 23 | movableView.addGestureRecognizer(gesture) 24 | } 25 | 26 | private var referencePoint: CGPoint = .zero 27 | 28 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 29 | switch recognizer.state { 30 | case .began: 31 | referencePoint = recognizer.location(in: movableView) 32 | case .changed: 33 | let location = recognizer.location(in: view) 34 | var f = movableView.frame 35 | f.origin = location - referencePoint 36 | movableView.frame = f 37 | case .ended, .cancelled: 38 | snapBack() 39 | default: 40 | break 41 | } 42 | } 43 | 44 | private func snapBack() { 45 | UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 1, options: [.allowUserInteraction, .beginFromCurrentState, .allowAnimatedContent], animations: { 46 | var f = self.movableView.frame 47 | f.origin = self.initialPosition 48 | self.movableView.frame = f 49 | }, completion: nil) 50 | } 51 | 52 | } 53 | 54 | PlaygroundPage.current.liveView = DemoController() 55 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Pages/4 - Bounce Back Interruptible.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PlaygroundSupport 3 | 4 | final class DemoController: UIViewController { 5 | 6 | private let initialPosition = CGPoint(x: 50, y: 50) 7 | 8 | private lazy var movableView: UIView = { 9 | let v = UIView(frame: CGRect(origin: initialPosition, size: CGSize(width: 100, height: 100))) 10 | v.backgroundColor = .systemBlue 11 | v.layer.cornerRadius = 22 12 | v.layer.cornerCurve = .continuous 13 | return v 14 | }() 15 | 16 | override func loadView() { 17 | view = UIView() 18 | view.backgroundColor = .systemBackground 19 | 20 | view.addSubview(movableView) 21 | 22 | let gesture = InstantPanGestureRecognizer(target: self, action: #selector(handlePan)) 23 | view.addGestureRecognizer(gesture) 24 | } 25 | 26 | private var referencePoint: CGPoint = .zero 27 | 28 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 29 | let location = recognizer.location(in: view) 30 | 31 | switch recognizer.state { 32 | case .began: 33 | animator?.stopAnimation(true) 34 | 35 | movableView.origin = location 36 | case .changed: 37 | movableView.origin = location 38 | case .ended, .cancelled: 39 | snapBack() 40 | default: 41 | break 42 | } 43 | } 44 | 45 | private var animator: UIViewPropertyAnimator? 46 | 47 | private func snapBack() { 48 | animator = UIViewPropertyAnimator(duration: 5, curve: .easeInOut, animations: nil) 49 | animator?.isUserInteractionEnabled = true 50 | animator?.isInterruptible = true 51 | animator?.addAnimations { 52 | self.movableView.origin = self.initialPosition 53 | } 54 | animator?.startAnimation() 55 | } 56 | 57 | } 58 | 59 | PlaygroundPage.current.liveView = DemoController() 60 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Pages/5 - Snap with Momentum.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PlaygroundSupport 3 | 4 | final class DemoController: UIViewController { 5 | 6 | private lazy var momentumLabel: UILabel = { 7 | let l = UILabel() 8 | l.text = "Momentum" 9 | l.textColor = .secondaryLabel 10 | return l 11 | }() 12 | 13 | private lazy var momentumSwitch: UISwitch = { 14 | let s = UISwitch() 15 | s.isOn = false 16 | return s 17 | }() 18 | 19 | private lazy var momentumStack: UIStackView = { 20 | let v = UIStackView(arrangedSubviews: [self.momentumLabel, self.momentumSwitch]) 21 | v.axis = .horizontal 22 | v.spacing = 8 23 | v.translatesAutoresizingMaskIntoConstraints = false 24 | return v 25 | }() 26 | 27 | private lazy var movableView: UIView = { 28 | let v = UIView(frame: .zero) 29 | v.backgroundColor = .systemBlue 30 | v.layer.cornerRadius = 22 31 | v.layer.cornerCurve = .continuous 32 | return v 33 | }() 34 | 35 | private lazy var dockingStation: UIView = { 36 | let v = UIView(frame: CGRect(x: 50, y: 400, width: 100, height: 100)) 37 | v.backgroundColor = .systemGray6 38 | v.layer.cornerRadius = 22 39 | v.layer.cornerCurve = .continuous 40 | return v 41 | }() 42 | 43 | override func loadView() { 44 | view = UIView() 45 | view.backgroundColor = .systemBackground 46 | 47 | view.addSubview(dockingStation) 48 | view.addSubview(movableView) 49 | view.addSubview(momentumStack) 50 | 51 | NSLayoutConstraint.activate([ 52 | momentumStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), 53 | momentumStack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16), 54 | ]) 55 | 56 | let moveGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 57 | movableView.addGestureRecognizer(moveGesture) 58 | 59 | let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(reset)) 60 | doubleTapGesture.numberOfTapsRequired = 2 61 | view.addGestureRecognizer(doubleTapGesture) 62 | } 63 | 64 | @objc private func reset() { 65 | movableView.frame = CGRect(x: view.bounds.width-150, y: 50, width: 100, height: 100) 66 | } 67 | 68 | override func viewDidAppear(_ animated: Bool) { 69 | super.viewDidAppear(animated) 70 | 71 | DispatchQueue.main.asyncAfter(deadline: .now()+1) { 72 | self.movableView.frame = CGRect(x: self.view.bounds.width-150, y: 50, width: 100, height: 100) 73 | self.dockingStation.frame = CGRect(x: 50, y: self.view.bounds.height-150, width: 100, height: 100) 74 | } 75 | } 76 | 77 | private var referencePoint: CGPoint = .zero 78 | 79 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 80 | let velocity = recognizer.velocity(in: view) 81 | 82 | switch recognizer.state { 83 | case .began: 84 | animator?.stopAnimation(true) 85 | 86 | referencePoint = recognizer.location(in: movableView) 87 | case .changed: 88 | let location = recognizer.location(in: view) 89 | movableView.origin = location - referencePoint 90 | case .ended, .cancelled: 91 | snapToDockingStation(with: velocity) 92 | default: 93 | break 94 | } 95 | } 96 | 97 | private var animator: UIViewPropertyAnimator? 98 | 99 | private func timingCurve(with velocity: CGFloat) -> FluidTimingCurve { 100 | let damping: CGFloat = velocity.isZero ? 100 : 30 101 | 102 | return FluidTimingCurve( 103 | velocity: CGVector(dx: velocity, dy: velocity), 104 | stiffness: 400, 105 | damping: damping 106 | ) 107 | } 108 | 109 | private func snapToDockingStation(with velocity: CGPoint) { 110 | // the 0.5 is to ensure there's always some distance for the gesture to work with 111 | let distanceY = (movableView.frame.origin.y - 0.5) - dockingStation.frame.origin.y 112 | 113 | let effectiveVelocity = velocity.y.isInfinite || velocity.y.isNaN ? 2000 : velocity.y 114 | 115 | let initialVelocityY = distanceY.isZero ? 0 : effectiveVelocity/distanceY * -1 116 | 117 | let timing: FluidTimingCurve 118 | 119 | if momentumSwitch.isOn { 120 | timing = timingCurve(with: initialVelocityY) 121 | } else { 122 | timing = FluidTimingCurve(velocity: CGVector(dx: 1, dy: 1), stiffness: 400, damping: 30) 123 | } 124 | 125 | animator = UIViewPropertyAnimator(duration: 2, timingParameters: timing) 126 | animator?.addAnimations { 127 | self.movableView.origin = self.dockingStation.origin 128 | } 129 | animator?.startAnimation() 130 | } 131 | 132 | } 133 | 134 | PlaygroundPage.current.liveView = DemoController() 135 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Pages/6 - Snap with Projection.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PlaygroundSupport 3 | 4 | final class DemoController: UIViewController { 5 | 6 | private lazy var movableView: UIView = { 7 | let v = UIView(frame: .zero) 8 | v.backgroundColor = .systemBlue 9 | v.layer.cornerRadius = 22 10 | v.layer.cornerCurve = .continuous 11 | return v 12 | }() 13 | 14 | private func makeDockingStation() -> UIView { 15 | let v = UIView() 16 | v.backgroundColor = .systemGray6 17 | v.layer.cornerRadius = 22 18 | v.layer.cornerCurve = .continuous 19 | return v 20 | } 21 | 22 | private lazy var dockingStationTopRight: UIView = { 23 | makeDockingStation() 24 | }() 25 | private lazy var dockingStationBottomRight: UIView = { 26 | makeDockingStation() 27 | }() 28 | private lazy var dockingStationBottomLeft: UIView = { 29 | makeDockingStation() 30 | }() 31 | 32 | private lazy var dockingStations: [UIView] = { 33 | [dockingStationTopRight, dockingStationBottomLeft, dockingStationBottomRight] 34 | }() 35 | 36 | override func loadView() { 37 | view = UIView() 38 | view.backgroundColor = .systemBackground 39 | 40 | view.addSubview(dockingStationTopRight) 41 | view.addSubview(dockingStationBottomRight) 42 | view.addSubview(dockingStationBottomLeft) 43 | view.addSubview(movableView) 44 | 45 | let moveGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 46 | movableView.addGestureRecognizer(moveGesture) 47 | } 48 | 49 | @objc private func reset() { 50 | movableView.frame = CGRect(x: view.bounds.width-150, y: 50, width: 100, height: 100) 51 | } 52 | 53 | override func viewDidAppear(_ animated: Bool) { 54 | super.viewDidAppear(animated) 55 | 56 | DispatchQueue.main.asyncAfter(deadline: .now()+1) { 57 | self.movableView.frame = CGRect(x: self.view.bounds.width-150, y: 50, width: 100, height: 100) 58 | self.dockingStationTopRight.frame = self.movableView.frame 59 | self.dockingStationBottomLeft.frame = CGRect(x: 50, y: self.view.bounds.height-150, width: 100, height: 100) 60 | self.dockingStationBottomRight.frame = CGRect(x: self.movableView.frame.origin.x, y: self.view.bounds.height-150, width: 100, height: 100) 61 | } 62 | } 63 | 64 | private var referencePoint: CGPoint = .zero 65 | 66 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 67 | let velocity = recognizer.velocity(in: view) 68 | 69 | switch recognizer.state { 70 | case .began: 71 | animator?.stopAnimation(true) 72 | 73 | referencePoint = recognizer.location(in: movableView) 74 | case .changed: 75 | let location = recognizer.location(in: view) 76 | movableView.origin = location - referencePoint 77 | case .ended, .cancelled: 78 | snapToClosestDockingStation(from: movableView.origin, with: velocity) 79 | default: 80 | break 81 | } 82 | } 83 | 84 | private var animator: UIViewPropertyAnimator? 85 | 86 | private func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat { 87 | return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate) 88 | } 89 | 90 | func projectedTargetPosition(with velocity: CGVector, from position: CGPoint) -> CGPoint { 91 | var velocity = velocity 92 | 93 | // We want to reduce movement along the secondary axis of the gesture. 94 | if velocity.dx != 0 || velocity.dy != 0 { 95 | let velocityInPrimaryDirection = max(abs(velocity.dx), abs(velocity.dy)) 96 | 97 | velocity.dx *= abs(velocity.dx / velocityInPrimaryDirection) 98 | velocity.dy *= abs(velocity.dy / velocityInPrimaryDirection) 99 | } 100 | 101 | let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue 102 | 103 | return CGPoint( 104 | x: position.x - 0.001 * velocity.dx / log(decelerationRate), 105 | y: position.y - 0.001 * velocity.dy / log(decelerationRate) 106 | ) 107 | } 108 | 109 | private func closestDockingStation(from currentOrigin: CGPoint, with velocity: CGPoint) -> CGPoint { 110 | let projectedPosition = projectedTargetPosition(with: CGVector(dx: velocity.x, dy: velocity.y), from: currentOrigin) 111 | return dockingStations.min(by: { projectedPosition.distance(to: $0.center) < projectedPosition.distance(to: $1.center) })!.origin 112 | } 113 | 114 | private func snapToClosestDockingStation(from currentOrigin: CGPoint, with velocity: CGPoint) { 115 | let closest = closestDockingStation(from: currentOrigin, with: velocity) 116 | snapToDockingStation(at: closest, with: velocity) 117 | } 118 | 119 | private func timingCurve(with velocity: CGVector) -> FluidTimingCurve { 120 | let damping: CGFloat = velocity.dy.isZero ? 100 : 30 121 | 122 | return FluidTimingCurve( 123 | velocity: velocity, 124 | stiffness: 400, 125 | damping: damping 126 | ) 127 | } 128 | 129 | private func snapToDockingStation(at position: CGPoint, with velocity: CGPoint) { 130 | // the 0.5 is to ensure there's always some distance for the gesture to work with 131 | let distanceX = (movableView.frame.origin.x - 0.5) - position.x 132 | let distanceY = (movableView.frame.origin.y - 0.5) - position.y 133 | 134 | let initialVelocityX = abs(velocity.x/distanceX) 135 | let initialVelocityY = abs(velocity.y/distanceY) 136 | 137 | let timing = timingCurve(with: CGVector(dx: initialVelocityX, dy: initialVelocityY)) 138 | 139 | animator = UIViewPropertyAnimator(duration: 2, timingParameters: timing) 140 | animator?.addAnimations { 141 | self.movableView.origin = position 142 | } 143 | animator?.startAnimation() 144 | } 145 | 146 | } 147 | 148 | PlaygroundPage.current.liveView = DemoController() 149 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Pages/7 - Snap with Projection++.xcplaygroundpage/Contents.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PlaygroundSupport 3 | 4 | final class DemoController: UIViewController { 5 | 6 | private lazy var movableView: UIView = { 7 | let v = UIView(frame: .zero) 8 | v.backgroundColor = .systemBlue 9 | v.layer.cornerRadius = 22 10 | v.layer.cornerCurve = .continuous 11 | return v 12 | }() 13 | 14 | private func makeDockingStation() -> UIView { 15 | let v = UIView() 16 | v.layer.borderColor = UIColor.systemBlue.cgColor 17 | v.layer.borderWidth = 1 18 | v.layer.cornerRadius = 22 19 | v.layer.cornerCurve = .continuous 20 | return v 21 | } 22 | 23 | private lazy var dockingStationTopRight: UIView = { 24 | makeDockingStation() 25 | }() 26 | private lazy var dockingStationBottomRight: UIView = { 27 | makeDockingStation() 28 | }() 29 | private lazy var dockingStationBottomLeft: UIView = { 30 | makeDockingStation() 31 | }() 32 | 33 | private lazy var dockingStations: [UIView] = { 34 | [dockingStationTopRight, dockingStationBottomLeft, dockingStationBottomRight] 35 | }() 36 | 37 | override func loadView() { 38 | view = UIView() 39 | view.backgroundColor = .systemBackground 40 | 41 | dockingStations.forEach(view.addSubview) 42 | 43 | view.addSubview(movableView) 44 | 45 | let moveGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 46 | movableView.addGestureRecognizer(moveGesture) 47 | } 48 | 49 | override func viewDidAppear(_ animated: Bool) { 50 | super.viewDidAppear(animated) 51 | 52 | DispatchQueue.main.asyncAfter(deadline: .now()+1) { 53 | self.setupViews() 54 | } 55 | } 56 | 57 | private func setupViews() { 58 | movableView.frame = CGRect(x: view.bounds.width-150, y: 50, width: 100, height: 100) 59 | dockingStationTopRight.frame = movableView.frame 60 | dockingStationBottomLeft.frame = CGRect(x: 50, y: view.bounds.height-150, width: 100, height: 100) 61 | dockingStationBottomRight.frame = CGRect(x: movableView.frame.origin.x, y: view.bounds.height-150, width: 100, height: 100) 62 | } 63 | 64 | private var referencePoint: CGPoint = .zero 65 | 66 | @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { 67 | let velocity = recognizer.velocity(in: view) 68 | 69 | switch recognizer.state { 70 | case .began: 71 | animator?.stopAnimation(true) 72 | 73 | referencePoint = recognizer.location(in: movableView) 74 | case .changed: 75 | let location = recognizer.location(in: view) 76 | movableView.origin = location - referencePoint 77 | case .ended, .cancelled: 78 | snapToClosestDockingStation(from: movableView.origin, with: velocity) 79 | default: 80 | break 81 | } 82 | } 83 | 84 | private func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat { 85 | return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate) 86 | } 87 | 88 | func projectedTargetPosition(with velocity: CGVector, from position: CGPoint) -> CGPoint { 89 | var velocity = velocity 90 | 91 | // Make sure the dominant axis has more weight when projecting the final position 92 | if velocity.dx != 0 || velocity.dy != 0 { 93 | let velocityInPrimaryDirection = max(abs(velocity.dx), abs(velocity.dy)) 94 | 95 | velocity.dx *= abs(velocity.dx / velocityInPrimaryDirection) 96 | velocity.dy *= abs(velocity.dy / velocityInPrimaryDirection) 97 | } 98 | 99 | let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue 100 | 101 | return CGPoint( 102 | x: position.x - 0.001 * velocity.dx / log(decelerationRate), 103 | y: position.y - 0.001 * velocity.dy / log(decelerationRate) 104 | ) 105 | } 106 | 107 | private func closestDockingStation(from currentOrigin: CGPoint, with velocity: CGPoint) -> CGPoint { 108 | let projectedPosition = projectedTargetPosition(with: CGVector(dx: velocity.x, dy: velocity.y), from: currentOrigin) 109 | return dockingStations.min(by: { projectedPosition.distance(to: $0.center) < projectedPosition.distance(to: $1.center) })!.origin 110 | } 111 | 112 | private func snapToClosestDockingStation(from currentOrigin: CGPoint, with velocity: CGPoint) { 113 | let closest = closestDockingStation(from: currentOrigin, with: velocity) 114 | snapToDockingStation(at: closest, with: velocity) 115 | } 116 | 117 | private func timingCurve(with velocity: CGVector) -> FluidTimingCurve { 118 | let damping: CGFloat = velocity.dy.isZero ? 100 : 30 119 | 120 | return FluidTimingCurve( 121 | velocity: velocity, 122 | stiffness: 400, 123 | damping: damping 124 | ) 125 | } 126 | 127 | private var animator: UIViewPropertyAnimator? 128 | private var bounceAnimator: UIViewPropertyAnimator? 129 | 130 | private func snapToDockingStation(at position: CGPoint, with velocity: CGPoint) { 131 | // the 0.5 is to ensure there's always some distance for the gesture to work with 132 | let distanceX = (movableView.frame.origin.x - 0.5) - position.x 133 | let distanceY = (movableView.frame.origin.y - 0.5) - position.y 134 | 135 | var initialVelocityX = abs(velocity.x/distanceX) 136 | var initialVelocityY = abs(velocity.y/distanceY) 137 | 138 | if initialVelocityX.isInfinite || initialVelocityX.isNaN { initialVelocityX = 1 } 139 | if initialVelocityY.isInfinite || initialVelocityY.isNaN { initialVelocityY = 1 } 140 | 141 | let timing = timingCurve(with: CGVector(dx: initialVelocityX, dy: initialVelocityY)) 142 | 143 | animator = UIViewPropertyAnimator(duration: 0, timingParameters: timing) 144 | bounceAnimator = UIViewPropertyAnimator(duration: animator!.duration, curve: .easeOut, animations: nil) 145 | 146 | animator?.addAnimations { 147 | self.movableView.origin = position 148 | } 149 | 150 | bounceAnimator?.addAnimations({ 151 | self.movableView.layer.transform = CATransform3DMakeScale(0.6, 0.6, 1) 152 | }, delayFactor: 0) 153 | bounceAnimator?.addAnimations({ 154 | self.movableView.layer.transform = CATransform3DIdentity 155 | }, delayFactor: 0.24) 156 | 157 | animator?.startAnimation() 158 | bounceAnimator?.startAnimation() 159 | } 160 | 161 | } 162 | 163 | PlaygroundPage.current.liveView = DemoController() 164 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Sources/FluidTimingCurve.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class FluidTimingCurve: NSObject, UITimingCurveProvider { 4 | 5 | public let initialVelocity: CGVector 6 | let mass: CGFloat 7 | let stiffness: CGFloat 8 | let damping: CGFloat 9 | 10 | public init(velocity: CGVector, stiffness: CGFloat = 400, damping: CGFloat = 30, mass: CGFloat = 1.0) { 11 | self.initialVelocity = velocity 12 | self.stiffness = stiffness 13 | self.damping = damping 14 | self.mass = mass 15 | 16 | super.init() 17 | } 18 | 19 | public func encode(with aCoder: NSCoder) { 20 | fatalError("Not supported") 21 | } 22 | 23 | public init?(coder aDecoder: NSCoder) { 24 | fatalError("Not supported") 25 | } 26 | 27 | public func copy(with zone: NSZone? = nil) -> Any { 28 | return FluidTimingCurve(velocity: initialVelocity, stiffness: stiffness, damping: damping, mass: mass) 29 | } 30 | 31 | public var timingCurveType: UITimingCurveType { 32 | return .composed 33 | } 34 | 35 | public var cubicTimingParameters: UICubicTimingParameters? { 36 | return .init(animationCurve: .easeIn) 37 | } 38 | 39 | public var springTimingParameters: UISpringTimingParameters? { 40 | return UISpringTimingParameters(mass: mass, stiffness: stiffness, damping: damping, initialVelocity: initialVelocity) 41 | } 42 | 43 | } 44 | 45 | public extension UISpringTimingParameters { 46 | 47 | /// A design-friendly way to create a spring timing curve. 48 | /// 49 | /// - Parameters: 50 | /// - damping: The 'bounciness' of the animation. Value must be between 0 and 1. 51 | /// - response: The 'speed' of the animation. 52 | /// - initialVelocity: The vector describing the starting motion of the property. Optional, default is `.zero`. 53 | convenience init(damping: CGFloat, response: CGFloat, initialVelocity: CGVector = .zero) { 54 | let stiffness = pow(2 * .pi / response, 2) 55 | let damp = 4 * .pi * damping / response 56 | self.init(mass: 1, stiffness: stiffness, damping: damp, initialVelocity: initialVelocity) 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Sources/Geometry.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public extension CGPoint { 4 | 5 | static func -(lhs: CGPoint, rhs: CGPoint) -> CGPoint { 6 | return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y) 7 | } 8 | 9 | static func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint { 10 | return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y) 11 | } 12 | 13 | static func *(lhs: CGPoint, rhs: CGPoint) -> CGPoint { 14 | return CGPoint(x: lhs.x * rhs.x, y: lhs.y * rhs.y) 15 | } 16 | 17 | static func /(lhs: CGPoint, rhs: CGPoint) -> CGPoint { 18 | return CGPoint(x: lhs.x / rhs.x, y: lhs.y / rhs.y) 19 | } 20 | 21 | func distance(to other: CGPoint) -> CGFloat { 22 | return hypot(other.x - self.x, other.y - self.y) 23 | } 24 | 25 | var absolute: CGPoint { 26 | return CGPoint(x: abs(x), y: abs(y)) 27 | } 28 | 29 | } 30 | 31 | public extension UIView { 32 | var origin: CGPoint { 33 | get { frame.origin } 34 | set { 35 | var f = self.frame 36 | f.origin = newValue 37 | self.frame = f 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/Sources/InstantPanGestureRecognizer.swift: -------------------------------------------------------------------------------- 1 | 2 | import UIKit 3 | import UIKit.UIGestureRecognizerSubclass 4 | 5 | public final class InstantPanGestureRecognizer: UIPanGestureRecognizer { 6 | public override func touchesBegan(_ touches: Set, with event: UIEvent) { 7 | super.touchesBegan(touches, with: event) 8 | self.state = .began 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Fluid_Interactions_Mac.playground/timeline.xctimeline: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Guilherme Rambo 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | - Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fluid animations and interactions on iOS apps 2 | 3 | This repository includes a playground book that can be used with Swift Playgrounds on iPad (recommended) and also a regular Xcode playground. These playgrounds were made to demonstrate some of the techniques I talked about in my "Fluid animations and interactions on iOS apps" talk at BA:Swiftable. 4 | 5 | I also recommend reading [this excellent post by Christian Schnorr](https://medium.com/ios-os-x-development/demystifying-uikit-spring-animations-2bb868446773) to learn more about the math behind these techniques. 6 | 7 | This [open-source sheet implementation](https://github.com/insidegui/sheet) can also be used to learn more about iOS interactions and animations. --------------------------------------------------------------------------------