Search code examples
iosswiftuikit

How can I properly animate a UIBezierPath to have a water/wave effect?


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
}

Solution

  • 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