├── .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.
--------------------------------------------------------------------------------