Skip to content

Instantly share code, notes, and snippets.

@erica
Created March 12, 2020 22:10
Show Gist options
  • Save erica/fd7b9b41c69c745d7e0553dd4cba1c91 to your computer and use it in GitHub Desktop.
Save erica/fd7b9b41c69c745d7e0553dd4cba1c91 to your computer and use it in GitHub Desktop.
Breathe Animation Challenge
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.
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