车载仪表盘上的油量表想要做出真实感,光有一条水平的液位线是不够的。真实世界里的液体在车辆启停、转弯时会来回晃动,液面呈现明显的波浪起伏。要在iOS端还原这种效果,Core Graphics配合正弦波函数是最直接的方案:正弦波提供周期性的波动形态,阻尼系数模拟液体逐渐恢复平静的过程,再借助CADisplayLink做逐帧重绘,就能得到一个流畅且符合直觉的液位动画。本文从数学模型、绘制实现到性能优化,完整拆解这套方案。

一、正弦波模拟液面的数学原理
液体的晃动本质上是一种往复运动,而正弦函数 y = A * sin(ωx + φ) 恰好能描述这种形态。在这个公式里,A控制波峰到波谷的高度,也就是晃动的剧烈程度;ω决定波长,值越小波浪越宽越平缓,符合油箱里液体惯性感较强的特点;φ是相位,随时间推移不断变化,正是它让整个波形产生横向移动的动态感。
单一正弦波看起来会比较规则,真实液体的表面往往是几个不同频率的波叠加的结果。所以在实现时通常用两到三个正弦波叠加:一个低频大振幅的波承担整体倾斜感,一个高频小振幅的波叠加细节纹理,视觉上会自然很多。振幅随时间按指数衰减,公式可以写成 A(t) = A0 * e^(-kt),当外部刺激停止后,液面会在一两秒内逐渐平静下来,这个衰减过程是真实感的关键。
另外还有一个容易被忽略的细节:液面中心的位置。当车辆加速时,液面整体会向后倾斜,减速则向前倾斜。这个效果可以通过给液面添加一个随速度变化的倾斜角度来实现,也就是在正弦波的基础上叠加一个线性项,让液面不再是绕着中心对称晃动,而是整体带一个偏移。
二、Core Graphics绘制液位路径的实现
绘制思路是先把油量表的圆形或圆角矩形容器画出来,再用一条由多个点连成的波浪曲线作为液面,把波形以下的区域填充为液体颜色。具体做法是沿x轴方向每隔2到3个像素采样一次正弦函数,把每个采样点连成CGPath,最后闭合到容器底部。下面是核心的绘制函数:
class FuelGaugeView: UIView {
// 波动参数
private var phase: CGFloat = 0 // 当前相位
private var amplitude: CGFloat = 0 // 当前振幅
private var targetAmplitude: CGFloat = 0 // 目标振幅
private var fillLevel: CGFloat = 0.5 // 油量 0~1
private func liquidPath(in rect: CGRect) -> UIBezierPath {
let liquidRect = bounds.insetBy(dx: 8, dy: 8)
let surfaceY = liquidRect.maxY - fillLevel * liquidRect.height
let path = UIBezierPath()
path.move(to: CGPoint(x: liquidRect.minX, y: surfaceY))
let step: CGFloat = 3
var x = liquidRect.minX
while x <= liquidRect.maxX {
// 两个正弦波叠加,模拟真实液面
let y = surfaceY
+ sin((x / liquidRect.width) * .pi * 4 + phase) * amplitude
+ sin((x / liquidRect.width) * .pi * 7 + phase * 1.7) * amplitude * 0.4
path.addLine(to: CGPoint(x: x, y: y))
x += step
}
// 闭合到容器底部
path.addLine(to: CGPoint(x: liquidRect.maxX, y: liquidRect.maxY))
path.addLine(to: CGPoint(x: liquidRect.minX, y: liquidRect.maxY))
path.close()
return path
}
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
// 裁剪出容器的圆角形状
let container = UIBezierPath(roundedRect: bounds.insetBy(dx: 8, dy: 8),
cornerRadius: 20)
ctx.addPath(container.cgPath)
ctx.clip()
// 填充液体
UIColor.systemBlue.setFill()
liquidPath(in: rect).fill()
}
}
这段代码里有几个点值得注意。采样步长设为3像素是一个平衡值,步长太大波形会出现明显的折线感,太小则会增加路径点数量影响性能。两个正弦波的频率设置为4π和7π,相位速度略有差异,叠加后波形不会呈现出机械的重复感。
液体颜色建议做一点渐变处理,用CGGradient从液面往下由浅入深,再在液面附近画一条半透明白色的高光线,模拟液体表面对环境光的反射。这个小细节对整体质感的提升非常明显,成本却几乎可以忽略。
三、CADisplayLink驱动动画与物理参数控制
波形是动态的,必须逐帧更新相位和振幅。CADisplayLink是与屏幕刷新率同步的定时器,用它驱动重绘比Timer更平滑。在回调里做三件事:推进相位、让振幅向目标值过渡、调用setNeedsDisplay触发重绘。
extension FuelGaugeView {
func startAnimating() {
let link = CADisplayLink(target: self, selector: #selector(tick))
link.add(to: .main, forMode: .common)
}
@objc private func tick() {
phase += 0.08
// 振幅向目标值平滑过渡,模拟阻尼
amplitude += (targetAmplitude - amplitude) * 0.05
// 附加一个随速度产生的倾斜偏移
setNeedsDisplay()
}
// 外部接口:车辆状态变化时调用
func setVehicleMotion(acceleration: CGFloat, turning: CGFloat) {
// 加速度和转向越剧烈,目标振幅越大
targetAmplitude = min(10, abs(acceleration) * 20 + abs(turning) * 12)
}
}
振幅过渡采用简单的线性插值,系数0.05意味着每帧向目标靠近百分之五,换算下来大约一秒内完成大部分过渡,这个手感接近真实液体的惯性。当targetAmplitude归零后,amplitude会逐渐衰减到0,液面自然恢复平静,不需要额外写衰减逻辑。
与车速联动是提升沉浸感的部分。可以通过CoreMotion获取设备的加速度数据,也可以直接接收车机总线传来的车速信号,把加速度和横向摆动映射为目标振幅。刹车时液面前倾、起步时后仰,再叠加正弦波晃动,仪表盘就活起来了。
四、性能优化与常见问题处理
逐帧重绘对CPU有一定压力,尤其当仪表盘页面上还有转速表、速度表等多个控件同时刷新时。优化手段主要有三个:第一,把LiquidGaugeView的绘制放到独立的layer上,或者改用CAShapeLayer配合displayLink更新path属性,让动画走隐式提交,避免整块视图重绘;第二,将isOpaque设为false的同时确保backgroundColor为clear,避免无意义的透明度合成;第三,采样点数量控制在100个以内,肉眼上已经足够平滑。
还有一个常见问题是液面超过容器边界。当fillLevel接近1时,波形可能超出容器顶部,解法是在填充液体前先对容器路径做裁剪,前面代码里的clip调用就是为此准备的。反过来当fillLevel接近0时,波形可能悬空露底,可以在绘制前对surfaceY做一次clamp,保证液面最低点仍然贴着容器底部。
最后提一下适配问题。如果液位控件要在不同尺寸的屏幕上使用,振幅和波长参数应该按控件宽度做归一化,例如振幅取宽度的百分之几,而不是写死像素值,这样在大屏车机和小屏手机上才能保持一致的视觉比例。整套方案的代码量不大,但波形叠加、阻尼过渡、运动联动这三个环节缺一不可,把它们组合好,油量表的液态质感就能明显上一个台阶。
Core GraphicsiOS动画正弦波修改时间:2026-09-12 22:12:39