绘制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.width
,unitWidth
的范围从 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
值。
所以,嗯。就是这样。
我一直在尝试了解如何在 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.width
,unitWidth
的范围从 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
值。
所以,嗯。就是这样。