3

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.stack.imgur.com/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
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
Nick
  • 75
  • 1
  • 8

1 Answers1

6

A couple of observations:

  1. 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.

  2. You are updating at 11 frames per second (fps). If you want it to be smooth, leave this at the device default.

  3. 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).

  4. You’re missing a line to the lower right corner of the view, right before you close the path.

  5. 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
    }
    
  6. 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.

  7. 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.

  8. 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.

  9. 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:

enter image description here

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • 1
    Hi this works great and thank you for the help. I was wondering if there would be an easy adjustment for this to allow for differing heights throughout the wave. Similar to this https://blog.darksky.net/wp-content/uploads/2016/05/android1.jpg If it would be a huge change, no worries. I have just been trying to figure it out for a while. – Nick Mar 22 '20 at 20:49
  • 2
    @Nick - I don’t see any “wave” in that screen snapshot, but yes, you can have differing heights throughout the wave. The above animated curve is a simple sine curve. But if you make `f` the sum of two different sine curves (e.g. perhaps this second sine curve with smaller amplitude but longer period). Depending upon periods, amplitudes, etc., you can end up with all sorts of variations. E.g. Here’s a subtle one: https://gist.github.com/robertmryan/427e7d4d74f4c4e878c8755e68fd9d13 – Rob Mar 23 '20 at 01:05