I am attempting to make a UIBezierPath
that animates like a wave or water. Similar to something like this.
https://dribbble.com/shots/3994990-Waves-Loading-Animation
I am using this animation as a sort of line graph with data points (0-100). I have the path drawn correctly but am having trouble properly animating it.
It currently looks like this https://i.sstatic.net/jXLEK.jpg with the movement super ridged/fast
let dataPoints: [Double]
var displayLink: CADisplayLink?
var startTime: CFAbsoluteTime?
let background: UIView = {
let view = UIView()
return view
}()
let shapeLayer: CAShapeLayer = {
let layer = CAShapeLayer()
return layer
}()
init(frame: CGRect, data: [Double], precip: [String]) {
self.dataPoints = data
super.init(frame: frame)
addSubview(background)
background.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 0, height: 0)
}
override func layoutSubviews() {
super.layoutSubviews()
background.layer.addSublayer(shapeLayer)
shapeLayer.strokeColor = UIColor.waterColor.cgColor
shapeLayer.fillColor = UIColor.waterColor.cgColor
startDisplayLink()
}
func wave(at elapsed: Double) -> UIBezierPath {
let maxX = bounds.width
let maxY = bounds.height
func f(_ y: Double) -> CGFloat {
let random = CGFloat.random(in: 1.0...5.0)
return CGFloat(y) + sin(CGFloat(elapsed/2) * random * .pi)
}
func z(_ x: CGFloat) -> CGFloat {
let random = CGFloat.random(in: 1.0...2.0)
let position = Int.random(in: 0...1)
if(position == 0) {
return x + random
} else {
return x - random
}
}
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: maxY))
let steps = bounds.width/CGFloat(24)
var start: CGFloat = steps
for i in 0..<24 {
let x = z(start)
let y = maxY - f(dataPoints[i]*100)
let point = CGPoint(x: x, y: y)
path.addLine(to: point)
start+=steps
}
path.close()
return path
}
func startDisplayLink() {
startTime = CFAbsoluteTimeGetCurrent()
displayLink?.invalidate()
displayLink = CADisplayLink(target: self, selector:#selector(handleDisplayLink(_:)))
displayLink?.add(to: .current, forMode: .common)
displayLink?.preferredFramesPerSecond = 11
}
func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
}
@objc func handleDisplayLink(_ displayLink: CADisplayLink) {
let elapsed = CFAbsoluteTimeGetCurrent() - startTime!
shapeLayer.path = wave(at: elapsed).cgPath
}
A couple of observations:
You are calling random
inside f(_:)
and z(_:)
. That means that every time you call either one of these, you’re going to get a different random value every time. So it’s going to jump around wildly.
You want to move these to constants, define the random parameters once up front, and use these same factors from that point on.
You are updating at 11 frames per second (fps). If you want it to be smooth, leave this at the device default.
You are rendering 24 points. Unless you start using bezier curves (as contemplated in my answer to your other question), that’s going to yield a very chunky output. I’d bump that up (e.g. on an iPhone, 200 yields a fairly smooth looking wave).
You’re missing a line to the lower right corner of the view, right before you close the path.
Your wave function isn’t quite right, returning x
plus the sine function (which will yield a diagonal wave). Also, if you want it to feel like waves, I’d not only vary the amplitude of the wave as a function of the elapsed time, but I’d also vary the overall tidal height as well. E.g.
let maxAmplitude: CGFloat = 0.1
let maxTidalVariation: CGFloat = 0.1
let amplitudeOffset = CGFloat.random(in: -0.5 ... 0.5)
let amplitudeChangeSpeedFactor = CGFloat.random(in: 4 ... 8)
let defaultTidalHeight: CGFloat = 0.50
let saveSpeedFactor = CGFloat.random(in: 4 ... 8)
func wave(at elapsed: Double) -> UIBezierPath {
func f(_ x: Double) -> CGFloat {
let elapsed = CGFloat(elapsed)
let amplitude = maxAmplitude * abs(fmod(CGFloat(elapsed/2), 3) - 1.5)
let variation = sin((elapsed + amplitudeOffset) / amplitudeChangeSpeedFactor) * maxTidalVariation
let value = sin((elapsed / saveSpeedFactor + CGFloat(x)) * 4 * .pi)
return value * amplitude / 2 * bounds.height + (defaultTidalHeight + variation) * bounds.height
}
let path = UIBezierPath()
path.move(to: CGPoint(x: bounds.minX, y: bounds.maxY))
for dataPoint in dataPoints {
let x = CGFloat(dataPoint) * bounds.width + bounds.minX
let y = bounds.maxY - f(dataPoint)
let point = CGPoint(x: x, y: y)
path.addLine(to: point)
}
path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.maxY))
path.close()
return path
}
Note that the documentation for CFAbsoluteTimeGetCurrent
warns us that
Repeated calls to this function do not guarantee monotonically increasing results.
I’d suggest using CACurrentMediaTime
instead.
I’d suggest losing dataPoints
altogether. It offers no value. I’d just go ahead and calculate the dataPoint
from the width of the view.
I’d stick with the standard init(frame:)
. That way you can both add the view programmatically as well as add it directly in Interface Builder.
Remember to invalidate your display link in deinit
.
Thus:
@IBDesignable
class WavyView: UIView {
private weak var displayLink: CADisplayLink?
private var startTime: CFTimeInterval = 0
private let maxAmplitude: CGFloat = 0.1
private let maxTidalVariation: CGFloat = 0.1
private let amplitudeOffset = CGFloat.random(in: -0.5 ... 0.5)
private let amplitudeChangeSpeedFactor = CGFloat.random(in: 4 ... 8)
private let defaultTidalHeight: CGFloat = 0.50
private let saveSpeedFactor = CGFloat.random(in: 4 ... 8)
private lazy var background: UIView = {
let background = UIView()
background.translatesAutoresizingMaskIntoConstraints = false
background.layer.addSublayer(shapeLayer)
return background
}()
private let shapeLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.strokeColor = UIColor.waterColor.cgColor
shapeLayer.fillColor = UIColor.waterColor.cgColor
return shapeLayer
}()
override init(frame: CGRect = .zero) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
override func willMove(toSuperview newSuperview: UIView?) {
super.willMove(toSuperview: newSuperview)
if newSuperview == nil {
displayLink?.invalidate()
}
}
override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
shapeLayer.path = wave(at: 0)?.cgPath
}
}
private extension WavyView {
func configure() {
addSubview(background)
background.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 0, height: 0)
startDisplayLink()
}
func wave(at elapsed: Double) -> UIBezierPath? {
guard bounds.width > 0, bounds.height > 0 else { return nil }
func f(_ x: CGFloat) -> CGFloat {
let elapsed = CGFloat(elapsed)
let amplitude = maxAmplitude * abs(fmod(elapsed / 2, 3) - 1.5)
let variation = sin((elapsed + amplitudeOffset) / amplitudeChangeSpeedFactor) * maxTidalVariation
let value = sin((elapsed / saveSpeedFactor + x) * 4 * .pi)
return value * amplitude / 2 * bounds.height + (defaultTidalHeight + variation) * bounds.height
}
let path = UIBezierPath()
path.move(to: CGPoint(x: bounds.minX, y: bounds.maxY))
let count = Int(bounds.width / 10)
for step in 0 ... count {
let dataPoint = CGFloat(step) / CGFloat(count)
let x = dataPoint * bounds.width + bounds.minX
let y = bounds.maxY - f(dataPoint)
let point = CGPoint(x: x, y: y)
path.addLine(to: point)
}
path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.maxY))
path.close()
return path
}
func startDisplayLink() {
startTime = CACurrentMediaTime()
displayLink?.invalidate()
let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
displayLink.add(to: .main, forMode: .common)
self.displayLink = displayLink
}
func stopDisplayLink() {
displayLink?.invalidate()
}
@objc func handleDisplayLink(_ displayLink: CADisplayLink) {
let elapsed = CACurrentMediaTime() - startTime
shapeLayer.path = wave(at: elapsed)?.cgPath
}
}
That yields: