-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathAutomatticAppLogosCell.swift
334 lines (262 loc) · 12 KB
/
AutomatticAppLogosCell.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
import UIKit
import SpriteKit
import CoreMotion
enum HapticsState {
case active
case paused
}
/// A table view cell that contains a SpriteKit game scene which shows logos
/// of the various apps from Automattic.
///
class AutomatticAppLogosCell: UITableViewCell {
var hapticsState: HapticsState = .active {
didSet {
logosScene.hapticsState = hapticsState
}
}
private var logosScene: AppLogosScene!
private var spriteKitView: SKView!
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
self.commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
self.commonInit()
}
func commonInit() {
spriteKitView = SKView(frame: Metrics.sceneFrame)
spriteKitView.allowsTransparency = true
logosScene = AppLogosScene()
// Scene is resized to match the view
logosScene.scaleMode = .resizeFill
spriteKitView.presentScene(logosScene)
contentView.addSubview(spriteKitView)
spriteKitView.translatesAutoresizingMaskIntoConstraints = false
configureConstraints()
}
private func configureConstraints() {
let edgeConstraints: [NSLayoutConstraint] = [
contentView.leadingAnchor.constraint(equalTo: spriteKitView.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: spriteKitView.trailingAnchor)
]
edgeConstraints.forEach({ $0.priority = .defaultLow })
var constraints: [NSLayoutConstraint] = [
contentView.leadingAnchor.constraint(lessThanOrEqualTo: spriteKitView.leadingAnchor),
contentView.trailingAnchor.constraint(greaterThanOrEqualTo: spriteKitView.trailingAnchor),
contentView.topAnchor.constraint(equalTo: spriteKitView.topAnchor),
contentView.bottomAnchor.constraint(equalTo: spriteKitView.bottomAnchor),
spriteKitView.widthAnchor.constraint(lessThanOrEqualToConstant: Metrics.maxWidth),
spriteKitView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor)
]
constraints.append(contentsOf: edgeConstraints)
NSLayoutConstraint.activate(constraints)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
logosScene.updateForTraitCollection(traitCollection)
}
enum Metrics {
static let sceneFrame = CGRect(x: 0, y: 0, width: 350.0, height: 150.0)
static let maxWidth: CGFloat = 388.0 // Standard cell width on Max phone
static var cellHeight: CGFloat {
sceneFrame.height
}
}
}
/// Displays the logos of the various Automattic apps in balls that collide
/// with one another.
///
private class AppLogosScene: SKScene {
private struct App {
let color: UIColor
let image: String
}
private let apps: [App] = [
App(color: UIColor(red: 0.49, green: 0.34, blue: 0.64, alpha: 1.00), image: "woo"),
App(color: UIColor(red: 0.00, green: 0.10, blue: 0.21, alpha: 1.00), image: "tumblr"),
App(color: UIColor(red: 0.20, green: 0.38, blue: 0.80, alpha: 1.00), image: "simplenote"),
App(color: UIColor(red: 0.96, green: 0.24, blue: 0.22, alpha: 1.00), image: "pocketcasts"),
App(color: UIColor(red: 0.27, green: 0.75, blue: 1.00, alpha: 1.00), image: "dayone"),
App(color: UIColor(red: 0.00, green: 0.75, blue: 0.16, alpha: 1.00), image: "jetpack"),
]
// Collision categories
private let ballCategory: UInt32 = 0b0010
private let edgeCategory: UInt32 = 0b0001
// Stores a reference to each of the balls in the scene
private var balls: [SKNode] = []
private let motionManager = CMMotionManager()
private var traitCollection: UITraitCollection?
// Haptics
fileprivate var softGenerator = UIImpactFeedbackGenerator(style: .soft)
fileprivate var rigidGenerator = UIImpactFeedbackGenerator(style: .rigid)
var hapticsState: HapticsState = .active
// Keeps track of the last time a specific physics body made contact.
// Used to limit the number of haptics impacts we trigger as a result of collisions.
fileprivate var contacts: [SKPhysicsBody: TimeInterval] = [:]
private var bounds: CGRect {
view?.bounds ?? .zero
}
// MARK: - Scene lifecycle
override func didMove(to view: SKView) {
super.didMove(to: view)
motionManager.startAccelerometerUpdates()
generateScene()
scene?.physicsWorld.contactDelegate = self
}
deinit {
motionManager.stopAccelerometerUpdates()
}
override func didChangeSize(_ oldSize: CGSize) {
super.didChangeSize(oldSize)
if oldSize != size {
generateScene()
}
}
func updateForTraitCollection(_ traitCollection: UITraitCollection) {
self.traitCollection = traitCollection
// We need to manually update the scene for dark mode / light mode.
// We'll also regenerate the balls to ensure they use the correct image.
backgroundColor = .secondarySystemGroupedBackground.resolvedColor(with: traitCollection)
generateBalls()
}
// MARK: - Scene creation
private func generateScene() {
backgroundColor = .clear
let edge = SKPhysicsBody(edgeLoopFrom: frame)
edge.categoryBitMask = edgeCategory
edge.collisionBitMask = ballCategory
physicsBody = edge
generateBalls()
}
private func generateBalls() {
// Remove any existing balls
balls.forEach({ $0.removeFromParent() })
balls.removeAll()
guard let bounds = view?.bounds,
bounds.size != .zero else {
return
}
balls = apps.compactMap({ makeBall(for: $0) })
balls.forEach({ addChild($0) })
}
private func makeBall(for app: App) -> SKNode? {
guard let view = view,
let image = UIImage(named: Constants.appLogoPrefix + app.image, in: .module, compatibleWith: traitCollection) else {
return nil
}
// Container for the various parts of the ball
let ball = SKShapeNode(circleOfRadius: Metrics.ballRadius)
ball.fillColor = .secondarySystemGroupedBackground
ball.strokeColor = .secondarySystemGroupedBackground
// For the background, we first draw a shape node at full opacity...
let background = SKShapeNode(circleOfRadius: Metrics.ballRadius)
background.strokeColor = app.color
background.fillColor = app.color
// ... Then turn that into a sprite with the correct alpha.
// We can't just apply an alpha to the background shape node, as the
// fill covers the stroke and their values are added together resulting
// in a darker stroke. We also can't just set a clear stroke,
// otherwise the fill won't be antialiased.
let backgroundSprite = SKSpriteNode(texture: view.texture(from: background))
backgroundSprite.alpha = Metrics.backgroundAlpha
ball.addChild(backgroundSprite)
// Add the logo, taking into account the current trait collection for dark mode
let logo = SKSpriteNode(texture: SKTexture(image: image))
logo.size = CGSize(width: Metrics.ballWidth, height: Metrics.ballWidth)
ball.addChild(logo)
let physicsBody = SKPhysicsBody(circleOfRadius: Metrics.ballRadius)
physicsBody.categoryBitMask = ballCategory
physicsBody.collisionBitMask = ballCategory | edgeCategory
physicsBody.contactTestBitMask = ballCategory
physicsBody.affectedByGravity = true
physicsBody.restitution = Constants.physicsRestitution
ball.physicsBody = physicsBody
// Ensure we only spawn balls in an area in the center that's inset
// from either side by the radius of a ball plus some padding
let spawnArea = bounds.insetBy(dx: Metrics.edgePadding + Metrics.ballRadius,
dy: Metrics.edgePadding + Metrics.ballRadius)
ball.position = CGPoint(x: spawnArea.minX + CGFloat(arc4random_uniform(UInt32(spawnArea.width))),
y: spawnArea.minY + CGFloat(arc4random_uniform(UInt32(spawnArea.height))))
return ball
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.randomElement() else {
return
}
let location = touch.location(in: self)
let touchedNodes = nodes(at: location)
for node in touchedNodes {
if node is SKShapeNode {
let actions = SKAction.sequence([
SKAction.scale(to: Metrics.tapScaleUpValue, duration: Metrics.tapScaleUpDuration),
SKAction.scale(to: Metrics.tapDefaultScaleValue, duration: Metrics.tapDefaultScaleDuration)
])
node.run(actions)
}
}
}
enum Metrics {
static let ballRadius: CGFloat = 36.0
static let logoSize: CGFloat = 40.0
static let backgroundAlpha: CGFloat = 0.16
static let edgePadding: CGFloat = 4.0 // So we don't spawn balls too close to the edges
static let tapScaleUpValue: CGFloat = 1.4
static let tapScaleUpDuration: TimeInterval = 0.05
static let tapDefaultScaleValue: CGFloat = 1.0
static let tapDefaultScaleDuration: TimeInterval = 0.1
static var ballWidth: CGFloat {
ballRadius * 2.0
}
}
enum Constants {
static let appLogoPrefix = "logo-"
static let physicsRestitution: CGFloat = 0.5
static let phyicsContactDebounce: TimeInterval = 0.25
static let hapticsImpulseThreshold: TimeInterval = 0.10
static let gravityModifier: CGFloat = 9.8
}
override func update(_ currentTime: TimeInterval) {
if let accelerometerData = motionManager.accelerometerData {
let acceleration = accelerometerData.acceleration
let gravity = gravityVector(with: acceleration)
physicsWorld.gravity = CGVector(dx: gravity.dx * Constants.gravityModifier, dy: gravity.dy * Constants.gravityModifier)
}
}
private func gravityVector(with acceleration: CMAcceleration) -> CGVector {
guard UIDevice.current.userInterfaceIdiom == .pad else {
// iPhone locks the interface orientation, so we can just use the acceleration as-is
return CGVector(dx: acceleration.x, dy: acceleration.y)
}
// iPad rotates the interface so we need to change the gravity acceleration to match
switch UIDevice.current.orientation {
case .portraitUpsideDown:
return CGVector(dx: -acceleration.x, dy: -acceleration.y)
case .landscapeLeft:
return CGVector(dx: -acceleration.y, dy: acceleration.x)
case .landscapeRight:
return CGVector(dx: acceleration.y, dy: -acceleration.x)
default:
return CGVector(dx: acceleration.x, dy: acceleration.y)
}
}
}
extension AppLogosScene: SKPhysicsContactDelegate {
func didBegin(_ contact: SKPhysicsContact) {
guard hapticsState == .active else {
return
}
let currentTime = CACurrentMediaTime()
// If we trigger a haptics impact for every single impact it feels a bit much,
// so we'll ignore concurrent contacts for the same physics body within a small timeout.
if let timestamp = contacts[contact.bodyA],
currentTime - timestamp < Constants.phyicsContactDebounce {
return
}
// We'll use a soft generator for collisions with a small impulse
// and a rigid generator for harder collisions so we have some variety in the feedback.
let generator: UIImpactFeedbackGenerator = contact.collisionImpulse < Constants.hapticsImpulseThreshold ? softGenerator : rigidGenerator
generator.impactOccurred()
contacts[contact.bodyA] = currentTime
}
}