iosswiftgraphicsspectrum

Drawing the WaveForm effect of Siri


I have been trying to understand how to draw Siri's wave effect in iOS and came across this great repository. The final result looks like this :

enter image description here

However I am having difficulty understanding what is going on with the code that generates the waves.I can generate a single static sine wave but this, I don't quite seem to understand.

Particularly when we calculate the value of y , why does it have to be :

let y = scaling * maxAmplitude * normedAmplitude * sin(CGFloat(2 * M_PI) * self.frequency * (x / self.bounds.width) + self.phase) + self.bounds.height/2.0

Source Code:

 //MARK : Properties 


let density : CGFloat =        1
let frequency : CGFloat =      1.5
var phase :CGFloat =           0
var phaseShift:CGFloat =      -0.15
var numberOfWaves:Int =        6
var primaryLineWidth:CGFloat = 1.5
var idleAmplitude:CGFloat =    0.01
var waveColor:UIColor =        UIColor.white
var amplitude:CGFloat =        1.0 {
    didSet {
        amplitude = max(amplitude, self.idleAmplitude)
        self.setNeedsDisplay()
    }
}

Method

  override open func draw(_ rect: CGRect) {
    // Convenience function to draw the wave
    func drawWave(_ index:Int, maxAmplitude:CGFloat, normedAmplitude:CGFloat) {
        let path = UIBezierPath()
        let mid = self.bounds.width/2.0

        path.lineWidth = index == 0 ? self.primaryLineWidth : self.secondaryLineWidth

        for x in Swift.stride(from:0, to:self.bounds.width + self.density, by:self.density) {
            // Parabolic scaling
            let scaling = -pow(1 / mid * (x - mid), 2) + 1

  // The confusing part /////////////////////////////////////////
            let y = scaling * maxAmplitude * normedAmplitude *
     sin(CGFloat(2 * M_PI) * self.frequency * (x / self.bounds.width) + self.phase)  
+ self.bounds.height/2.0

  //////////////////////////////////////////////////////////////////
            if x == 0 {
                path.move(to: CGPoint(x:x, y:y))
            } else {
                path.addLine(to: CGPoint(x:x, y:y))
            }
        }

        path.stroke()
    }

    let context = UIGraphicsGetCurrentContext()
    context?.setAllowsAntialiasing(true)

    self.backgroundColor?.set()
    context?.fill(rect)

    let halfHeight = self.bounds.height / 2.0
    let maxAmplitude = halfHeight - self.primaryLineWidth

    for i in 0 ..< self.numberOfWaves {
        let progress = 1.0 - CGFloat(i) / CGFloat(self.numberOfWaves)
        let normedAmplitude = (1.5 * progress - 0.8) * self.amplitude
        let multiplier = min(1.0, (progress/3.0*2.0) + (1.0/3.0))
        self.waveColor.withAlphaComponent(multiplier * self.waveColor.cgColor.alpha).set()
        drawWave(i, maxAmplitude: maxAmplitude, normedAmplitude: normedAmplitude)
    }
    self.phase += self.phaseShift
}

Both the for loops seem very mathematical , I have no clue what's going on in there. Thanks in advance.


Solution

  • Here's a breakdown of the inner-most loop, which loops through x to draw the waveform. I'm going to get a little detailed in my explanation in the hopes that some bit of the additional info might be useful to others.

            for x in Swift.stride(from:0, to:self.bounds.width + self.density, by:self.density)
            {
    

    The loop iterates through the width of the UIView by a density increment. This allows control over two properties: (1) the 'resolution' of the waveform and (2) how long it spends generating the UIBezierPath that gets drawn. Simply setting density to 2 (in ViewController.swift) will cut the number of calculations in half as well as produce a path with half as many elements to draw. Increasing density by a full order of magnitude (10) may seem like too much, but you would be hard-pressed to notice a visual difference. Try setting the value to 100 if you want to see a triangle-wave.

    Side note: due to the use of stride(from:to:by:) if the view's width isn't evenly divisible by density, the waveform may stop short of the right side of the view, so + self.density was added.

                // Parabolic scaling
                let scaling = -pow(1 / mid * (x - mid), 2) + 1
    

    Have you noticed how the waveform seems to be attached to an anchor point on both sides of the screen? That's what this parabolic scaling is doing. To see it more clearly, you can plug this formula into Google's graphing functionality to get this:

    enter image description here

    Within that range, y follows a curve, yes, but notice how y starts at 0, rises to exactly 1.0 in the center, then falls back down to 0. More specifically, it does this within the range of x from 0 to 1. That's key because we'll be mapping this curve to the width of the view, where the left edge of the screen maps to x=0 and the right edge of the screen maps to x=1.

    If we map this curve to our on-screen waveform and use it to scale the amplitude (amplitude: the size of the waveform relative to its center-line) you'll see that the left and right endpoints of the waveform would have an amplitude of 0 (our anchor points) with the size of the waveform gradually increasing to full-size (1.0) in the center.

    To see the full effect of this scaling, try changing that line to let scaling = CGFloat(1.0).

    At this point, we're ready to chart the waveform. Here's the original line of code that the OP was asking about:

     let y = scaling * maxAmplitude * normedAmplitude *
     sin(CGFloat(2 * M_PI) * self.frequency * (x / self.bounds.width) + self.phase)  
     + self.bounds.height/2.0
    

    That's a lot to take in all at once. This code does the same exact thing, but I've broken it apart into temporary variables with appropriate names to aid in understanding what's going on:

    let unitWidth = x / self.bounds.width
    
    var wave = CGFloat(2 * M_PI)
    wave *= unitWidth
    wave *= self.frequency
    
    let wavePosition = wave + self.phase
    
    let waveUnitValue = sin(wavePosition)
    
    var amplitude = waveUnitValue * maxAmplitude
    amplitude *= scaling
    amplitude *= normedAmplitude
    
    let y = amplitude + self.bounds.height/2.0
    

    Okay, let's tackle this one bit at a time. We'll start with unitWidth. Remember when I mentioned that we were going to map the curve to the width of our screen? That's what this unitWidth calculation is doing: as x ranges from 0 to self.bounds.width, unitWidth will range from 0 to 1.

    Next up is wave. It is important to note that this value is intended for the purpose of calculating a sine wave. Note that the sin function works in Radians which means that the full period of a sine wave will range from 0 to 2π, so we'll start there (CGFloat(2 * M_PI)).

    We then apply our unitWidth to wave which determines where, within the sine wave we want to be for a given x position in the view. Think about it like this: Along the left side of the view, unitWidth is 0, so this multiplication results in 0 (the start of a sine wave.) Along the right-side of the view, unitWidth is 1.0 (giving us the full value 2π - the end of the sine wave.) If we're in the middle of the view, unitWidth will be 0.5, which would give us half-way through the full sine-wave period. And everything in-between. This is called interpolation. It is important to understand that we're not moving the sine wave, we're stepping through it.

    Next up, we apply self.frequency to wave. This scales the sine wave such that higher values have more hills and valleys. A frequency is 1 would not do anything and we'll follow the natural sine wave. But that's boring, so the frequency is increased a bit (1.5) in order to give a better visual appearance. Like salt, adjust to taste. Here it is at 3x the frequency:

    enter image description here

    So far, we've defined how our sine wave will look relative to the view that we're drawing it to. Our next task is to give it motion. To that end, we'll add self.phase to wave. This is called 'phase' because a phase is a distinct period within the waveform. By continuously changing self.phase for each frame of the animation, the drawing will start at a different position within the waveform, making it appear to move past the screen.

    Finally, we use wavePosition to calculate the actual sine wave value (let waveUnitValue = sin(wavePosition)). I've called this waveUnitValue because the result of sin() is a value that ranges from -1 to +1. If we drew it as-is, our wave would be pretty boring, resembling nearly a flat line:

    enter image description here

    "I've got a need... a need for amplitude"

    -- Nobody

    Our amplitude starts by applying a maxAmplitude to waveUnitValue, stretching it vertically. Why start with the maximum? If we go back to that calculation of the scaling variable, we'd be reminded that this is a unit value - a value that range from 0 to 1 - which means that it can only reduce the amplitude (or leave it unchanged) but not increase it.

    And that's exactly what we'll do next, apply our scaling value. This causes our waveform to have an amplitude of 0 at the ends, gradually increasing to full amplitude in the center. Without this, we would have something that looks like this:

    enter image description here

    Finally, we have normedAmplitude. If you follow the code, you'll see that the drawWave function is called within a loop in order to draw multiple waves into the view (this is where those secondary or 'shadow' waveforms come in.) The normedAmplitude is used to select a different amplitude for each of the waveforms drawn as part of the overall effect.

    It is interesting to note that the normedAmplitude can go negative, which allows for the shadow waveforms to be flipped vertically, filling in the waveform's empty spaces. Try changing the use of normedAmplitude in the original code to abs(normedAmplitude) and you'll see something like this (combined with the 3x frequency example to highlight the difference):

    enter image description here

    The last step is to center the waveform in the view (amplitude + self.bounds.height/2.0), which becomes the final y value we'll use to draw the waveform.

    So, um. That's it.