├── .gitignore
├── Resources
└── genie.gif
├── Genie.playground
├── Resources
│ └── SysPrefs.png
├── contents.xcplayground
├── Sources
│ ├── FloatingPoint+QuadraticEaseInOut.swift
│ └── CGRect+Normalized.swift
└── Contents.swift
├── README.md
└── LICENSE.md
/.gitignore:
--------------------------------------------------------------------------------
1 | xcuserdata/
2 | timeline.xctimeline
3 | playground.xcworkspace
--------------------------------------------------------------------------------
/Resources/genie.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HarshilShah/Genie/HEAD/Resources/genie.gif
--------------------------------------------------------------------------------
/Genie.playground/Resources/SysPrefs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HarshilShah/Genie/HEAD/Genie.playground/Resources/SysPrefs.png
--------------------------------------------------------------------------------
/Genie.playground/contents.xcplayground:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/Genie.playground/Sources/FloatingPoint+QuadraticEaseInOut.swift:
--------------------------------------------------------------------------------
1 | public extension FloatingPoint {
2 | var quadraticEaseInOut: Self {
3 | if self < 1 / 2 {
4 | return 2 * self * self
5 | } else {
6 | return (-2 * self * self) + (4 * self) - 1
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/Genie.playground/Sources/CGRect+Normalized.swift:
--------------------------------------------------------------------------------
1 | import CoreGraphics
2 |
3 | public extension CGRect {
4 | func normalized(in other: CGRect) -> CGRect {
5 | CGRect(
6 | x: (origin.x - other.origin.x) / other.width,
7 | y: (origin.y - other.origin.y) / other.height,
8 | width: width / other.width,
9 | height: height / other.height
10 | )
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Genie
2 |
3 | A Playground to recreate the macOS Genie Effect.
4 |
5 | For more information, please read the accompanying blog post: [Recreating the macOS Genie Effect](https://harshil.net/blog/recreating-the-mac-genie-effect).
6 |
7 | Note: The code has been changed a bit after the publication of the blog post. To see it as referenced there, see the [blog-post](https://github.com/HarshilShah/Genie/tree/blog-post) tag.
8 |
9 | 
10 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Harshil Shah
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Genie.playground/Contents.swift:
--------------------------------------------------------------------------------
1 | import SpriteKit
2 | import PlaygroundSupport
3 |
4 | enum GenieAnimationEdge {
5 | case top, bottom, left, right
6 |
7 | var isHorizontal: Bool {
8 | switch self {
9 | case .top, .bottom: return true
10 | case .left, .right: return false
11 | }
12 | }
13 | }
14 |
15 | enum GenieAnimationDirection {
16 | case minimize, maximize
17 | }
18 |
19 | func genie(maximized: CGRect, minimized: CGRect, direction: GenieAnimationDirection, edge: GenieAnimationEdge) -> SKAction {
20 | let slideAnimationEndFraction = 0.5
21 | let translateAnimationStartFraction = 0.4
22 |
23 | let duration = 0.7
24 | let fps = 60.0
25 | let frameCount = duration * fps
26 |
27 | let rowCount = edge.isHorizontal ? 50 : 1
28 | let columnCount = edge.isHorizontal ? 1 : 50
29 |
30 | let positions: [[SIMD2]] = {
31 | switch edge {
32 | case .top:
33 | let leftBezierTopX = Double(maximized.minX)
34 | let rightBezierTopX = Double(maximized.maxX)
35 |
36 | let leftEdgeDistanceToMove = Double(minimized.minX - maximized.minX)
37 | let rightEdgeDistanceToMove = Double(minimized.maxX - maximized.maxX)
38 | let verticalDistanceToMove = Double(minimized.maxY - maximized.maxY)
39 |
40 | let bezierTopY = Double(maximized.maxY)
41 | let bezierBottomY = Double(minimized.maxY)
42 | let bezierHeight = bezierTopY - bezierBottomY
43 |
44 | return stride(from: 0, to: frameCount, by: 1).map { frame in
45 | let fraction = (frame / (frameCount - 1))
46 | let slideProgress = max(0, min(1, fraction/slideAnimationEndFraction))
47 | let translateProgress = max(0, min(1, (fraction - translateAnimationStartFraction)/(1 - translateAnimationStartFraction)))
48 |
49 | let translation = translateProgress * verticalDistanceToMove
50 | let topEdgeVerticalPosition = Double(maximized.maxY) + translation
51 | let bottomEdgeVerticalPosition = max(
52 | Double(maximized.minY) + translation,
53 | Double(minimized.minY)
54 | )
55 |
56 | let leftBezierBottomX = leftBezierTopX + (slideProgress * leftEdgeDistanceToMove)
57 | let rightBezierBottomX = rightBezierTopX + (slideProgress * rightEdgeDistanceToMove)
58 |
59 | func leftBezierPosition(forY y: Double) -> Double {
60 | switch y {
61 | case .. Double {
72 | switch y {
73 | case .. [SIMD2] in
86 | let y = (topEdgeVerticalPosition * position) + (bottomEdgeVerticalPosition * (1 - position))
87 | let xMin = leftBezierPosition(forY: y)
88 | let xMax = rightBezierPosition(forY: y)
89 | return [SIMD2(xMin, y), SIMD2(xMax, y)]
90 | }
91 | .map(SIMD2.init)
92 | }
93 |
94 | case .bottom:
95 | let leftBezierBottomX = Double(maximized.minX)
96 | let rightBezierBottomX = Double(maximized.maxX)
97 |
98 | let leftEdgeDistanceToMove = Double(minimized.minX - maximized.minX)
99 | let rightEdgeDistanceToMove = Double(minimized.maxX - maximized.maxX)
100 | let verticalDistanceToMove = Double(minimized.minY - maximized.minY)
101 |
102 | let bezierTopY = Double(minimized.minY)
103 | let bezierBottomY = Double(maximized.minY)
104 | let bezierHeight = bezierTopY - bezierBottomY
105 |
106 | return stride(from: 0, to: frameCount, by: 1).map { frame in
107 | let fraction = (frame / (frameCount - 1))
108 | let slideProgress = max(0, min(1, fraction/slideAnimationEndFraction))
109 | let translateProgress = max(0, min(1, (fraction - translateAnimationStartFraction)/(1 - translateAnimationStartFraction)))
110 |
111 | let translation = translateProgress * verticalDistanceToMove
112 | let topEdgeVerticalPosition = min(
113 | Double(maximized.maxY) + translation,
114 | Double(minimized.maxY)
115 | )
116 | let bottomEdgeVerticalPosition = Double(maximized.minY) + translation
117 |
118 | let leftBezierTopX = leftBezierBottomX + (slideProgress * leftEdgeDistanceToMove)
119 | let rightBezierTopX = rightBezierBottomX + (slideProgress * rightEdgeDistanceToMove)
120 |
121 | func leftBezierPosition(forY y: Double) -> Double {
122 | switch y {
123 | case .. Double {
134 | switch y {
135 | case .. [SIMD2] in
148 | let y = (topEdgeVerticalPosition * position) + (bottomEdgeVerticalPosition * (1 - position))
149 | let xMin = leftBezierPosition(forY: y)
150 | let xMax = rightBezierPosition(forY: y)
151 | return [SIMD2(xMin, y), SIMD2(xMax, y)]
152 | }
153 | .map(SIMD2.init)
154 | }
155 |
156 | case .left:
157 | let topBezierLeftY = Double(maximized.maxY)
158 | let bottomBezierLeftY = Double(maximized.minY)
159 |
160 | let topEdgeDistanceToMove = Double(minimized.maxY - maximized.maxY)
161 | let bottomEdgeDistanceToMove = Double(minimized.minY - maximized.minY)
162 | let horizontalDistanceToMove = Double(minimized.minX - maximized.minX)
163 |
164 | let bezierLeftX = Double(maximized.minX)
165 | let bezierRightX = Double(minimized.minX)
166 | let bezierWidth = bezierRightX - bezierLeftX
167 |
168 | return stride(from: 0, to: frameCount, by: 1).map { frame in
169 | let fraction = (frame / (frameCount - 1))
170 | let slideProgress = max(0, min(1, fraction/slideAnimationEndFraction))
171 | let translateProgress = max(0, min(1, (fraction - translateAnimationStartFraction)/(1 - translateAnimationStartFraction)))
172 |
173 | let translation = translateProgress * horizontalDistanceToMove
174 | let leftEdgeHorizontalPosition = Double(maximized.minX) + translation
175 | let rightEdgeVerticalPosition = min(
176 | Double(maximized.maxX) + translation,
177 | Double(minimized.maxX)
178 | )
179 |
180 | let topBezierRightY = topBezierLeftY + (slideProgress * topEdgeDistanceToMove)
181 | let bottomBezierRightY = bottomBezierLeftY + (slideProgress * bottomEdgeDistanceToMove)
182 |
183 | func topBezierPosition(forX x: Double) -> Double {
184 | switch x {
185 | case .. Double {
196 | switch x {
197 | case .. SIMD2 in
210 | let x = (leftEdgeHorizontalPosition * (1 - position)) + (rightEdgeVerticalPosition * position)
211 | let y = topBezierPosition(forX: x)
212 | return SIMD2(x, y)
213 | }
214 | .map(SIMD2.init)
215 |
216 | let bottomEdgePositions = (0 ... columnCount)
217 | .map { Double($0) / Double(columnCount) }
218 | .map { position -> SIMD2 in
219 | let x = (leftEdgeHorizontalPosition * (1 - position)) + (rightEdgeVerticalPosition * position)
220 | let y = bottomBezierPosition(forX: x)
221 | return SIMD2(x, y)
222 | }
223 | .map(SIMD2.init)
224 |
225 | return bottomEdgePositions + topEdgePositions
226 | }
227 |
228 | case .right:
229 | let topBezierRightY = Double(maximized.maxY)
230 | let bottomBezierRightY = Double(maximized.minY)
231 |
232 | let topEdgeDistanceToMove = Double(minimized.maxY - maximized.maxY)
233 | let bottomEdgeDistanceToMove = Double(minimized.minY - maximized.minY)
234 | let horizontalDistanceToMove = Double(minimized.maxX - maximized.maxX)
235 |
236 | let bezierLeftX = Double(minimized.maxX)
237 | let bezierRightX = Double(maximized.maxX)
238 | let bezierWidth = bezierRightX - bezierLeftX
239 |
240 | return stride(from: 0, to: frameCount, by: 1).map { frame in
241 | let fraction = (frame / (frameCount - 1))
242 | let slideProgress = max(0, min(1, fraction/slideAnimationEndFraction))
243 | let translateProgress = max(0, min(1, (fraction - translateAnimationStartFraction)/(1 - translateAnimationStartFraction)))
244 |
245 | let translation = translateProgress * horizontalDistanceToMove
246 | let leftEdgeHorizontalPosition = max(
247 | Double(maximized.minX) + translation,
248 | Double(minimized.minX)
249 | )
250 | let rightEdgeVerticalPosition = Double(maximized.maxX) + translation
251 |
252 | let topBezierLeftY = topBezierRightY + (slideProgress * topEdgeDistanceToMove)
253 | let bottomBezierLeftY = bottomBezierRightY + (slideProgress * bottomEdgeDistanceToMove)
254 |
255 | func topBezierPosition(forX x: Double) -> Double {
256 | switch x {
257 | case .. Double {
268 | switch x {
269 | case .. SIMD2 in
282 | let x = (leftEdgeHorizontalPosition * (1 - position)) + (rightEdgeVerticalPosition * position)
283 | let y = topBezierPosition(forX: x)
284 | return SIMD2(x, y)
285 | }
286 | .map(SIMD2.init)
287 |
288 | let bottomEdgePositions = (0 ... columnCount)
289 | .map { Double($0) / Double(columnCount) }
290 | .map { position -> SIMD2 in
291 | let x = (leftEdgeHorizontalPosition * (1 - position)) + (rightEdgeVerticalPosition * position)
292 | let y = bottomBezierPosition(forX: x)
293 | return SIMD2(x, y)
294 | }
295 | .map(SIMD2.init)
296 |
297 | return bottomEdgePositions + topEdgePositions
298 | }
299 | }
300 | }()
301 |
302 | let orientedPositions = direction == .minimize ? positions : positions.reversed()
303 |
304 | let warps = orientedPositions.map {
305 | SKWarpGeometryGrid(columns: columnCount, rows: rowCount, destinationPositions: $0)
306 | }
307 |
308 | return SKAction.animate(
309 | withWarps: warps,
310 | times: warps.enumerated().map {
311 | NSNumber(value: Double($0.offset) / fps)
312 | }
313 | )!
314 | }
315 |
316 | let frame = CGRect(x: 0, y: 0, width: 800, height: 600)
317 | let skView = SKView(frame: frame)
318 | skView.appearance = NSAppearance(named: .aqua)
319 | PlaygroundPage.current.liveView = skView
320 |
321 | let scene = SKScene(size: frame.size)
322 | scene.backgroundColor = .windowBackgroundColor
323 |
324 | let imageNodes = [
325 | SKSpriteNode(imageNamed: "SysPrefs.png"),
326 | SKSpriteNode(imageNamed: "SysPrefs.png"),
327 | SKSpriteNode(imageNamed: "SysPrefs.png"),
328 | SKSpriteNode(imageNamed: "SysPrefs.png")
329 | ]
330 |
331 | imageNodes.forEach { imageNode in
332 | imageNode.position = CGPoint(x: frame.midX, y: frame.midY)
333 | imageNode.size = frame.size
334 | scene.addChild(imageNode)
335 | }
336 |
337 | skView.presentScene(scene)
338 |
339 | let initialFrame = CGRect(x: 200, y: 100, width: 400, height: 400)
340 | .normalized(in: skView.frame)
341 | let initialPositions = [
342 | SIMD2(Float(initialFrame.minX), Float(initialFrame.minY)),
343 | SIMD2(Float(initialFrame.maxX), Float(initialFrame.minY)),
344 | SIMD2(Float(initialFrame.minX), Float(initialFrame.maxY)),
345 | SIMD2(Float(initialFrame.maxX), Float(initialFrame.maxY))
346 | ]
347 | imageNodes.forEach { imageNode in
348 | imageNode.warpGeometry = SKWarpGeometryGrid(
349 | columns: 1,
350 | rows: 1,
351 | destinationPositions: initialPositions
352 | )
353 | }
354 |
355 | let endStates: [(edge: GenieAnimationEdge, origin: CGPoint)] = [
356 | (.top, CGPoint(x: 640, y: 0)),
357 | (.bottom, CGPoint(x: 120, y: 560)),
358 | (.right, CGPoint(x: 0, y: 80)),
359 | (.left, CGPoint(x: 760, y: 480)),
360 | ]
361 |
362 | zip(imageNodes, endStates).forEach { imageNode, endState in
363 | let finalFrame = CGRect(origin: endState.origin, size: CGSize(width: 40, height: 40))
364 | .normalized(in: skView.frame)
365 | let action = genie(
366 | maximized: initialFrame,
367 | minimized: finalFrame,
368 | direction: .minimize,
369 | edge: endState.edge
370 | )
371 | imageNode.run(
372 | action
373 | )
374 | }
375 |
--------------------------------------------------------------------------------