├── .gitignore ├── Genie.playground ├── Contents.swift ├── Resources │ └── SysPrefs.png ├── Sources │ ├── CGRect+Normalized.swift │ └── FloatingPoint+QuadraticEaseInOut.swift └── contents.xcplayground ├── LICENSE.md ├── README.md └── Resources └── genie.gif /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata/ 2 | timeline.xctimeline 3 | playground.xcworkspace -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Genie.playground/Resources/SysPrefs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HarshilShah/Genie/cafba78896965382786f38cbd529ec039ad01313/Genie.playground/Resources/SysPrefs.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ![](https://raw.github.com/HarshilShah/Genie/main/Resources/genie.gif) 10 | -------------------------------------------------------------------------------- /Resources/genie.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HarshilShah/Genie/cafba78896965382786f38cbd529ec039ad01313/Resources/genie.gif --------------------------------------------------------------------------------