Created
March 12, 2020 22:10
-
-
Save erica/fd7b9b41c69c745d7e0553dd4cba1c91 to your computer and use it in GitHub Desktop.
Breathe Animation Challenge
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
import PlaygroundSupport | |
extension Int { | |
var cg: CGFloat { return CGFloat(self) } | |
} | |
// Canvas size | |
let extent: CGFloat = 200 | |
let rect = CGRect(x: 0, y: 0, width: extent, height: extent) | |
let radius = extent / 3 | |
let center = CGPoint(x: rect.midX, y: rect.midY) | |
// How many circles | |
let numberOfCircles = 6 | |
let theta = 2 * CGFloat.pi / numberOfCircles.cg | |
// Hue, adding up to the color | |
let color = UIColor.cyan.withAlphaComponent(1.5 / numberOfCircles.cg) | |
// Duration of animation | |
let duration = 2.5 | |
// Establish framing view | |
let breatheView = UIView(frame: rect) | |
breatheView.backgroundColor = .black | |
breatheView.layer.borderColor = UIColor.black.cgColor | |
breatheView.layer.borderWidth = 1 | |
// Set backdrop that can be rotated | |
let breatheLayer = CALayer() | |
breatheLayer.frame = breatheView.frame | |
breatheView.layer.addSublayer(breatheLayer) | |
// A circle | |
let path = UIBezierPath(ovalIn: rect.insetBy(dx: radius / 2, dy: radius / 2)) | |
// Add the layers | |
let layers = (1 ... numberOfCircles).map({ count -> CAShapeLayer in | |
let layer = CAShapeLayer() | |
layer.frame = rect | |
layer.backgroundColor = UIColor.clear.cgColor | |
layer.fillColor = color.cgColor | |
layer.path = path.cgPath | |
layer.position = center | |
breatheLayer.addSublayer(layer) | |
return layer | |
}) | |
breatheView.layer.removeAllAnimations() | |
// Avoid a little duplication in code | |
func setRepeatDuration(_ duration: CFTimeInterval, for anim: CABasicAnimation) { | |
anim.repeatCount = .greatestFiniteMagnitude | |
anim.duration = duration | |
anim.autoreverses = true | |
anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) | |
} | |
for (index, layer) in layers.enumerated() { | |
let rotation = CABasicAnimation(keyPath: "transform") | |
// Scale and rotate | |
let origin = CATransform3DScale(CATransform3DIdentity, 0.15, 0.15, 1.0) | |
let destination = CATransform3DScale(CATransform3DMakeRotation(.pi/2, 0, 0, 1), 0.8, 0.8, 1.0) | |
rotation.fromValue = NSValue(caTransform3D: origin) | |
rotation.toValue = NSValue(caTransform3D: destination) | |
// Move from center outwards | |
let move = CABasicAnimation(keyPath: "position") | |
move.fromValue = NSValue(cgPoint: center) | |
move.toValue = NSValue(cgPoint: CGPoint(x: center.x + 0.5 * radius * cos(index.cg * theta), | |
y: center.y + 0.5 * radius * sin(index.cg * theta))) | |
// Emplace the animations | |
for anim in [rotation, move] { | |
setRepeatDuration(duration, for: anim) | |
layer.add(anim, forKey: nil) | |
} | |
} | |
// Rotate the canvas | |
let canvasRotate = CABasicAnimation(keyPath: "transform") | |
canvasRotate.fromValue = CATransform3DIdentity | |
canvasRotate.toValue = CATransform3DMakeRotation(.pi / 2, 0, 0, 1) | |
setRepeatDuration(duration, for: canvasRotate) | |
breatheLayer.add(canvasRotate, forKey: nil) | |
PlaygroundPage.current.needsIndefiniteExecution = true | |
PlaygroundPage.current.liveView = breatheView | |
// Yes, this was rushed and ugly and I knew I didn't like it. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import Foundation | |
/// The animation characteristics of an individual Breath presentation | |
struct Breath { | |
let petalCount: Int = 6 // number of petals | |
var contribution: CGFloat { 1.0 / CGFloat(petalCount) } // % contribution per petal | |
let coreColor: Color = Color(red: 0, green: 0.9, blue: 1.0) // petal color | |
let starterColor: Color = Color(red: 0, green: 0.5, blue: 0.6) // center color | |
let expansionDuration: CGFloat = 4.0 // time spent expanding and contracting | |
let startDiameter: CGFloat = 20 // initial size | |
let growthMultiplier: CGFloat = 6 // expansion degree | |
let offsetDistance: CGFloat = 40 // travel from center | |
let overallRotation: CGFloat = .pi / 2 // rotation of the outer layer | |
let backdropExtent: CGFloat = 400 // canvas extent | |
} | |
let breath = Breath() | |
/// Drives a breath animation consisting of growth, translation, and rotation | |
struct ContentView: View { | |
/// Specifies animation features | |
let breath: Breath | |
/// Drives animation forward and back | |
@State var isStartState = true | |
/// Provides repeating heartbeat to drive animation | |
let heartbeat = Animation | |
.easeInOut(duration: 4.0) | |
.repeatForever(autoreverses: true) | |
/// Returns the offset position for a ring index | |
private func offset(for idx: Int) -> CGSize { | |
let r = breath.offsetDistance * breath.contribution * 2 * .pi | |
let cidx = CGFloat(idx) | |
return CGSize(width: r * cos(cidx), height: r * sin(cidx)) | |
} | |
var body: some View { | |
ZStack { // Solid black backdrop, unaffected by rotation | |
ZStack { // Petals | |
Group { | |
ForEach(Range(1 ... breath.petalCount)) { (idx: Int) in | |
Circle() | |
.frame(width: self.breath.startDiameter, | |
height: self.breath.startDiameter, | |
alignment: .center) | |
.foregroundColor((self.isStartState | |
? self.breath.starterColor | |
: self.breath.coreColor) | |
.opacity(Double(self.breath.contribution))) | |
.scaleEffect(self.isStartState ? 1.0 : self.breath.growthMultiplier) | |
.offset(self.isStartState ? .zero : self.offset(for: idx)) | |
} | |
} | |
} | |
.frame(width: breath.backdropExtent, height: breath.backdropExtent, alignment: .center) // Drawing size | |
.rotationEffect(self.isStartState // Rotate Stack | |
? Angle(radians: 0.0) | |
: Angle(radians: Double(breath.overallRotation))) | |
.onAppear { // Animation Heartbeat | |
withAnimation(self.heartbeat) { self.isStartState.toggle() }} | |
} | |
.background(Color.black) | |
} | |
} | |
struct ContentView_Previews: PreviewProvider { | |
static var previews: some View { | |
ContentView(breath: breath) | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment