Skip to content

Instantly share code, notes, and snippets.

@phlippieb
Last active April 16, 2018 07:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save phlippieb/64a2c2392e13d422451907cbf7aeebb8 to your computer and use it in GitHub Desktop.
Save phlippieb/64a2c2392e13d422451907cbf7aeebb8 to your computer and use it in GitHub Desktop.
A UIView subclass that draws an animated light ribbon.
//
// 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