绘制Siri的WaveForm效果

Drawing the WaveForm effect of Siri

我一直在尝试了解如何在 iOS 中绘制 Siri 的波浪效果,并遇到了 this 很棒的存储库。 最终结果如下所示:

但是我很难理解生成 waves.I 的代码是怎么回事可以生成单个静态正弦波但是这个,我似乎不太明白。

特别是当我们计算 y 的值时,为什么它必须是:

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

源代码:

 //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()
    }
}

方法

  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
}

这两个 for 循环看起来都非常数学化,我不知道里面发生了什么。 提前致谢。

这是最内层循环的细分,它循环通过 x 以绘制波形。我将在我的解释中做一些详细的说明,希望一些额外的信息可能对其他人有用。

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

循环以 density 增量迭代 UIView 的宽度。这允许控制两个属性:(1) 波形的 'resolution' 和 (2) 生成绘制的 UIBezierPath 所花费的时间。只需将 density 设置为 2(在 ViewController.swift 中)即可将计算次数减半,并生成一条路径,其中要绘制的元素数量减半。将 density 增加一个完整的数量级 (10) 可能看起来太多了,但您很难注意到视觉差异。如果您想看到三角波,请尝试将值设置为 100

旁注:由于使用 stride(from:to:by:) 如果视图的宽度不能被 density 整除,波形可能会在视图的右侧停止,因此 [=添加了 25=]。

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

您是否注意到波形似乎与屏幕两侧的锚点相连?这就是抛物线缩放正在做的事情。为了更清楚地看到它,您可以 plug this formula 进入 Google 的图形功能来得到这个:

在该范围内,y 遵循曲线,是的,但请注意 y 如何从 0 开始,在中心上升到恰好 1.0,然后回落到 0。更具体地说,它在 x 从 0 到 1 的范围内执行此操作。这很关键,因为我们将把这条曲线映射到视图的宽度,其中屏幕的左边缘映射到 x=0 和屏幕右边缘映射到 x=1.

如果我们将此曲线映射到屏幕上的波形并使用它来缩放幅度(幅度:波形相对于其中心线的大小),您会看到波形的振幅为 0(我们的锚点),波形的大小在中心逐渐增加到全尺寸 (1.0)。

要查看此缩放的完整效果,请尝试将该行更改为 let scaling = CGFloat(1.0)

此时,我们已准备好绘制波形图。这是 OP 询问的原始代码行:

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

一次要吸收的东西太多了。这段代码做同样的事情,但我将它分解成具有适当名称的临时变量,以帮助理解发生了什么:

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

好的,让我们一次解决这个问题。我们将从 unitWidth 开始。还记得我提到过我们要将曲线映射到屏幕的宽度吗?这就是这个 unitWidth 计算正在做的事情:因为 x 的范围从 0 到 self.bounds.widthunitWidth 的范围从 0 到 1。

接下来是 wave。请务必注意,此值旨在用于计算正弦波。请注意,sin 函数以弧度为单位工作,这意味着正弦波的整个周期范围为 0 到 2π,因此我们将从那里开始 (CGFloat(2 * M_PI))。

然后我们将 unitWidth 应用到 wave,这决定了在正弦波中我们希望在视图中的给定 x 位置。可以这样想:在视图的左侧,unitWidth 为 0,因此乘法结果为 0(正弦波的开始)。在视图的右侧,unitWidth 是 1.0(给我们完整的值 2π - 正弦波的末端。)如果我们在视图的中间,unitWidth 将为 0.5,这将使我们通过完整的正弦波的一半-波浪时期。以及介于两者之间的一切。这称为插值。重要的是要了解我们不是在移动正弦波,而是在逐步通过它。

接下来,我们将 self.frequency 应用到 wave。这会缩放正弦波,使得更高的值具有更多的山丘和山谷。频率为 1 不会做任何事情,我们将遵循自然正弦波。但这很无聊,所以频率增加了一点(1.5)以提供更好的视觉外观。像盐一样,根据口味调整。这是频率的 3 倍:

到目前为止,我们已经定义了正弦波相对于我们将其绘制到的视图的外观。我们的下一个任务是让它运动。为此,我们将 self.phase 添加到 wave。这称为 'phase',因为相位是波形中的一个不同周期。通过为动画的每一帧连续更改 self.phase,绘图将从波形中的不同位置开始,使其看起来像是移过屏幕。

最后我们用wavePosition计算出实际的正弦波值(let waveUnitValue = sin(wavePosition))。我将其命名为 waveUnitValue 是因为 sin() 的结果是一个介于 -1 到 +1 之间的值。如果我们按原样绘制,我们的波浪会很无聊,几乎类似于一条平线:

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

-- Nobody

我们的 amplitude 首先将 maxAmplitude 应用到 waveUnitValue,然后垂直拉伸它。为什么要从最大值开始?如果我们回到 scaling 变量的计算,我们会被提醒这是一个单位值 - 一个范围从 0 到 1 的值 - 这意味着它只能降低振幅(或保留它不变)但不增加。

这正是我们接下来要做的,应用我们的 scaling 值。这导致我们的波形在末端的振幅为 0,在中心逐渐增加到全振幅。没有这个,我们就会有这样的东西:

最后,我们有 normedAmplitude。如果你遵循代码,你会看到 drawWave 函数在一个循环中被调用,以便将多个波绘制到视图中(这是那些次要或 'shadow' 波形出现的地方。) normedAmplitude 用于 select 作为整体效果的一部分绘制的每个波形的不同振幅。

值得注意的是,normedAmplitude 可以变为负值,这允许垂直翻转阴影波形,填充波形的空白空间。尝试将原始代码中 normedAmplitude 的用法更改为 abs(normedAmplitude) ,您会看到类似这样的内容(结合 3x 频率示例以突出差异):

最后一步是将波形置于视图中心 (amplitude + self.bounds.height/2.0),这将成为我们将用于绘制波形的最终 y 值。

所以,嗯。就是这样。