Last active
April 16, 2018 07:15
-
-
Save phlippieb/64a2c2392e13d422451907cbf7aeebb8 to your computer and use it in GitHub Desktop.
A UIView subclass that draws an animated light ribbon.
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
// | |
// LightRibbon2.swift | |
// Waveforms | |
// | |
// Created by Phlippie Bosman on 2018/04/12. | |
// Copyright © 2018 Phlippie Bosman. All rights reserved. | |
// | |
import UIKit | |
final class LightRibbonView: UIView { | |
/* Interface */ | |
typealias Fraction = CGFloat | |
typealias FractionPerInterval = CGFloat | |
/// The rate at which the ribbon phases. | |
/// Value is a fraction of the view's width to update at every animation interval. | |
open var phaseSpeed: FractionPerInterval = 0.003 | |
/// The rate at which the ribbon morphs towards the target ribbon along the x axis. | |
/// Value is a fraction of the view's width to update at every animation interval. | |
open var morphSpeedX: FractionPerInterval = 0.001 | |
/// The rate which the ribbon morphs towards the target ribbon along the y axis. | |
/// Value is a fraction of the view's height to update at every animation interval. | |
open var morphSpeedY: FractionPerInterval = 0.001 | |
/// The time interval at which the target ribbon is resampled. | |
open var remorphInterval: TimeInterval = 5.0 | |
/// The maximum height of the ribbon. | |
/// Value is a fraction of the view's height. | |
open var maxHeight: Fraction = 0.95 | |
/// The minimum height of the ribbon. | |
/// Value is a fraction of the view's height. | |
open var minHeight: Fraction = 0.35 | |
/// The maximum amount that ribbon's peaks can drift horizontally. | |
/// Value is a fraction of the default horizonal distance between peaks. | |
open var maxDrift: Fraction = 0.5 | |
/// The amount by which lines bend between peaks. | |
/// Lower values give straighter lines. | |
open var bend: CGFloat = 0.5 | |
/// The rate at which changes to the line "bendineess" are animated. | |
open var bendSpeed: CGFloat = 0.005 | |
/// The color of the (thin) lines in the ribbon. | |
open var lineColor: UIColor = UIColor.magenta | |
/// The width of the (thin) lines in the ribbon. | |
open var lineWidth: CGFloat = 1.0 | |
/// The color of the (thick) streaks between lines in the ribbon. | |
open var streakColor: UIColor = UIColor.magenta | |
/// The width of the (thick) streaks between lines in the ribbon. | |
open var streakWidth: CGFloat = 10.0 | |
open func startAnimating() { | |
self.animationTimer = Timer.scheduledTimer( | |
timeInterval: self.animationInterval, | |
target: self, | |
selector: #selector(animateUpdate), | |
userInfo: nil, | |
repeats: true) | |
self.remorphTimer = Timer.scheduledTimer( | |
timeInterval: self.remorphInterval, | |
target: self, | |
selector: #selector(remorph), | |
userInfo: nil, | |
repeats: true) | |
} | |
open func stopAnimating() { | |
self.animationTimer?.invalidate() | |
self.animationTimer = nil | |
self.remorphTimer?.invalidate() | |
self.remorphTimer = nil | |
} | |
/* State */ | |
private let animationInterval: TimeInterval = 1 / 60 | |
private var animationTimer: Timer? | |
private var remorphTimer: Timer? | |
private var phase: CGFloat = 0 | |
private let nAnchors: Int | |
private let nLines: Int | |
private var yAnchorsUpper: [CGFloat] = [] | |
private var yTargetsUpper: [CGFloat] = [] | |
private var xDriftsUpper: [CGFloat] = [] | |
private var xTargetsUpper: [CGFloat] = [] | |
private var yAnchorsLower: [CGFloat] = [] | |
private var yTargetsLower: [CGFloat] = [] | |
private var xDriftsLower: [CGFloat] = [] | |
private var xTargetsLower: [CGFloat] = [] | |
private var yBaselineMax: Fraction { return self.maxHeight / 10.0 } | |
private var yBaselineMin: Fraction { return self.yBaselineMax / 2.0 } | |
private var currentBend: CGFloat = 0.5 | |
init(numberOfAnchors: Int, numberOfLines: Int) { | |
self.nAnchors = numberOfAnchors | |
self.nLines = numberOfLines | |
super.init(frame: .zero) | |
self.yAnchorsUpper = self.sample(self.nAnchors, between: self.minHeight, and: self.maxHeight) | |
self.yTargetsUpper = self.sample(self.nAnchors, between: self.minHeight, and: self.maxHeight) | |
self.xDriftsUpper = self.sample(self.nAnchors, between: -self.maxDrift, and: self.maxDrift) | |
self.xTargetsUpper = self.sample(self.nAnchors, between: -self.maxDrift, and: self.maxDrift) | |
self.yAnchorsLower = self.sample(self.nAnchors, between: self.yBaselineMin, and: self.yBaselineMax) | |
self.yTargetsLower = self.sample(self.nAnchors, between: self.yBaselineMin, and: self.yBaselineMax) | |
self.xDriftsLower = self.sample(self.nAnchors, between: -self.maxDrift, and: self.maxDrift) | |
self.xTargetsLower = self.sample(self.nAnchors, between: -self.maxDrift, and: self.maxDrift) | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
/* Animation */ | |
@objc private func animateUpdate() { | |
self.updatePhase() | |
self.morph() | |
self.setNeedsDisplay() | |
} | |
private func updatePhase() { | |
self.phase += self.phaseSpeed | |
if self.phase < 0 || self.phase > 1 { | |
let adjustment: Int = (self.phase < 0) ? -1 : 1 | |
// Wrap the phase: | |
self.phase -= (CGFloat(adjustment)) | |
// Wrap the arrays: | |
self.yAnchorsUpper = self.array(self.yAnchorsUpper, rotatedBy: adjustment) | |
self.yTargetsUpper = self.array(self.yTargetsUpper, rotatedBy: adjustment) | |
self.xDriftsUpper = self.array(self.xDriftsUpper, rotatedBy: adjustment) | |
self.xTargetsUpper = self.array(self.xTargetsUpper, rotatedBy: adjustment) | |
self.yAnchorsLower = self.array(self.yAnchorsLower, rotatedBy: adjustment) | |
self.yTargetsLower = self.array(self.yTargetsLower, rotatedBy: adjustment) | |
self.xDriftsLower = self.array(self.xDriftsLower, rotatedBy: adjustment) | |
self.xTargetsLower = self.array(self.xTargetsLower, rotatedBy: adjustment) | |
} | |
} | |
private func morph() { | |
func sign(of val: CGFloat) -> CGFloat { | |
return (val < 0) ? -1 : 1 | |
} | |
for i in 0 ..< self.nAnchors { | |
// Morph upper y: | |
var dy = self.yTargetsUpper[i] - self.yAnchorsUpper[i] | |
if dy != 0 { | |
self.yAnchorsUpper[i] += sign(of: dy) * min(abs(dy), self.morphSpeedY) | |
} | |
// Morph lower y: | |
dy = self.yTargetsLower[i] - self.yAnchorsLower[i] | |
if dy != 0 { | |
self.yAnchorsLower[i] += sign(of: dy) * min(abs(dy), self.morphSpeedY) | |
} | |
// Morph upper x drift: | |
var dx = self.xTargetsUpper[i] - self.xDriftsUpper[i] | |
if dx != 0 { | |
self.xDriftsUpper[i] += sign(of: dx) * min(abs(dx), self.morphSpeedX) | |
} | |
// Morph lower x drift: | |
dx = self.xTargetsLower[i] - self.xDriftsLower[i] | |
if dx != 0 { | |
self.xDriftsLower[i] += sign(of: dx) * min(abs(dx), self.morphSpeedX) | |
} | |
} | |
let db = self.bend - self.currentBend | |
if db != 0 { | |
self.currentBend += sign(of: db) * min(abs(db), self.bendSpeed) | |
} | |
} | |
@objc private func remorph() { | |
self.yTargetsUpper = self.sample(self.nAnchors, between: self.minHeight, and: self.maxHeight) | |
self.xTargetsUpper = self.sample(self.nAnchors, between: -self.maxDrift, and: self.maxDrift) | |
self.yTargetsLower = self.sample(self.nAnchors, between: self.yBaselineMin, and: self.yBaselineMax) | |
self.xTargetsLower = self.sample(self.nAnchors, between: -self.maxDrift, and: self.maxDrift) | |
} | |
/* UI / drawing */ | |
override func draw(_ rect: CGRect) { | |
super.draw(rect) | |
let context = UIGraphicsGetCurrentContext() | |
context?.setAllowsAntialiasing(true) | |
self.backgroundColor?.set() | |
context?.fill(rect) | |
// A line is a pair of (yAnchors, xDrifts) | |
var lines: [([CGFloat], [CGFloat])] = [(self.yAnchorsUpper, self.xDriftsUpper)] | |
for n in 0 ..< self.nLines { | |
lines.append(self.makeFillerLine(n)) | |
} | |
lines.append((self.yAnchorsLower, self.xDriftsLower)) | |
// How many streaks for each line: | |
let streakPeriod = 5 | |
var width: CGFloat | |
for (i, line) in lines.enumerated() { | |
if i % streakPeriod == 0 { | |
self.lineColor.set() | |
width = self.lineWidth | |
} else { | |
self.streakColor.set() | |
width = self.streakWidth | |
} | |
self.draw(yAnchors: line.0, xDrifts: line.1, lineWidth: width) | |
} | |
} | |
private func makeFillerLine(_ n: Int) -> ([CGFloat], [CGFloat]) { | |
let nn = (CGFloat(n+1) / CGFloat(self.nLines + 1)) | |
let yAnchors: [CGFloat] = Array(0 ..< self.nAnchors).map { i in | |
let yRange = self.yAnchorsUpper[i] - self.yAnchorsLower[i] | |
return self.yAnchorsLower[i] + (yRange * nn) | |
} | |
let xDrifts: [CGFloat] = Array(0 ..< self.nAnchors).map { i in | |
let xRange = self.xDriftsUpper[i] - self.xDriftsLower[i] | |
return self.xDriftsLower[i] + (xRange * nn) | |
} | |
return (yAnchors, xDrifts) | |
} | |
private func draw(yAnchors: [CGFloat], xDrifts: [CGFloat], lineWidth: CGFloat) { | |
let path = UIBezierPath() | |
path.lineWidth = lineWidth | |
let v = self.bounds.width / CGFloat(self.nAnchors - 1) | |
let left = self.bounds.minX - v | |
let c: CGFloat = v * self.currentBend | |
var x = left + (v * self.phase) | |
var prevPoint: CGPoint = .zero | |
for i in 0 ..< self.nAnchors { | |
let _y = self.bounds.height - (yAnchors[i] * self.bounds.height) | |
let _x = x + (xDrifts[i] * v) | |
let point = CGPoint(x: _x, y: _y) | |
if i == 0 { | |
path.move(to: point) | |
} else { | |
path.addCurve( | |
to: point, | |
controlPoint1: CGPoint(x: prevPoint.x + c, y: prevPoint.y), | |
controlPoint2: CGPoint(x: point.x - c, y: point.y)) | |
} | |
x += v | |
prevPoint = point | |
} | |
// Re-use the first point as the last | |
let y = self.bounds.height - (yAnchors[0] * self.bounds.height) | |
let point = CGPoint(x: x, y: y) | |
path.addCurve( | |
to: point, | |
controlPoint1: CGPoint(x: prevPoint.x + c, y: prevPoint.y), | |
controlPoint2: CGPoint(x: point.x - c, y: point.y)) | |
path.stroke() | |
} | |
} | |
/* Util */ | |
extension LightRibbonView { | |
private func array<T>(_ array: Array<T>, rotatedBy amount: Int) -> Array<T> { | |
var copy = array | |
let amount = amount % copy.count | |
if amount > 0 { | |
let tail = copy.removeLast() | |
copy.insert(tail, at: 0) | |
return self.array(copy, rotatedBy: amount - 1) | |
} else if amount < 0 { | |
let head = copy.removeFirst() | |
copy.append(head) | |
return self.array(copy, rotatedBy: amount + 1) | |
} else { | |
return copy | |
} | |
} | |
private func rand() -> CGFloat { | |
return CGFloat(arc4random_uniform(1000)) / CGFloat(1000) | |
} | |
private func sample(_ n: Int, between min: CGFloat, and max: CGFloat) -> [CGFloat] { | |
let range = max - min | |
return Array(0 ..< n).map { _ in return min + (rand() * range) } | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment