燃油表是车载界面里最经典的控件之一:一个扇形表盘、若干分段刻度、底部一段像液体一样的填充区域,油量低的时候还要闪红灯提醒司机。这套效果用UIKit的默认控件拼不出来,最合适的做法是继承UIView,重写draw(_ rect:)方法,用Core Graphics逐层绘制。这篇文章就把整个实现过程拆开讲清楚:刻度怎么算角度、液位怎么画、动画怎么驱动、警告怎么闪。

一、表盘结构与角度计算
燃油表通常是240度左右的扇形,表盘下方留出开口。Core Graphics里角度是以弧度计量的,我们把表盘的起始角定为135度(左下方向),结束角定为405度(右下方向),正好扫过270度。为了方便后续计算,先定义两个常量,并用degreesToRadians扩展把角度转成弧度。
extension CGFloat {
func degreesToRadians() -> CGFloat {
return self * .pi / 180
}
}
class FuelGaugeView: UIView {
let startAngle: CGFloat = 135 // 起始角
let endAngle: CGFloat = 405 // 结束角(即45度)
let totalSweep: CGFloat = 270 // 总扫过角度
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) * 0.4
// 后续所有绘制都围绕 center 和 radius 展开
}
}需要注意一点:iOS的坐标系y轴向下,所以正角度在屏幕上是顺时针方向。135度在屏幕上指向左下方,405度等价于45度指向右下方,这正是我们想要的开口朝下的布局。如果把起止角弄反,画出来的扇形就会开口朝上,这是新手最容易踩的坑之一。
分段刻度的绘制思路是:把270度按照E到F的比例切分。假设表盘分为10个主刻度,每个主刻度之间再插入4个次刻度。刻度线本质是从半径R1到R2的线段,只需计算每个刻度对应的角度,再用CGContext的move和addLine画线即可。主刻度画粗一点、长一点,次刻度画细一点、短一点,视觉层次立刻就出来了。
func drawTicks(ctx: CGContext, center: CGPoint, radius: CGFloat) {
let mainTickCount = 11
let subTicksPerMain = 4
for i in 0...((mainTickCount - 1) * subTicksPerMain) {
let ratio = CGFloat(i) / CGFloat((mainTickCount - 1) * subTicksPerMain)
let angle = (startAngle + totalSweep * ratio).degreesToRadians()
let isMain = (i % subTicksPerMain == 0)
let innerR = isMain ? radius * 0.78 : radius * 0.84
let outerR = radius * 0.92
let x1 = center.x + innerR * cos(angle)
let y1 = center.y + innerR * sin(angle)
let x2 = center.x + outerR * cos(angle)
let y2 = center.y + outerR * sin(angle)
ctx.setStrokeColor(isMain ? UIColor.white.cgColor : UIColor.gray.cgColor)
ctx.setLineWidth(isMain ? 2.5 : 1.2)
ctx.move(to: CGPoint(x: x1, y: y1))
ctx.addLine(to: CGPoint(x: x2, y: y2))
ctx.strokePath()
}
}颜色分段可以在刻度内侧再画一圈弧线:从E开始的前六分之一用红色表示低油量区,中间大部分用白色或浅灰,靠近F的最后一段用绿色。画弧用addArc(center:radius:startAngle:endAngle:clockwise:),注意clockwise参数在iOS坐标系里传true才是屏幕上的顺时针,传反了会画出300度的大弧而不是你想要的小弧。
二、液位填充的绘制方法
液位是燃油表最有特色的部分:底部开口区域像液体一样填充到当前油量对应的高度。实现思路有两种。第一种是裁剪法:先把表盘扇形区域作为裁剪路径,然后在底部画一个矩形,矩形的高度由油量百分比决定,超出部分被裁掉,视觉上就是液体涨到了某个位置。第二种是扇形填充法:直接把135度到目标角度之间的扇形填上颜色。
两种方案各有优劣。扇形填充法实现简单,一次addArc加fillPath就完事,但看起来更像普通进度条,缺少液体的质感。裁剪法可以进一步加波浪效果:用两条贝塞尔曲线拼出波浪形的液面,再让波浪相位随时间偏移,液面就会有轻微晃动感。下面是裁剪法加静态波浪的示例。
func drawFuelLevel(ctx: CGContext, center: CGPoint, radius: CGFloat, level: CGFloat, phase: CGFloat) {
let liquidRect = CGRect(x: center.x - radius,
y: center.y - radius + (1 - level) * radius * 2,
width: radius * 2,
height: radius * 2)
// 构建表盘扇形作为裁剪区域
let clipPath = CGMutablePath()
clipPath.addArc(center: center, radius: radius * 0.88,
startAngle: startAngle.degreesToRadians(),
endAngle: endAngle.degreesToRadians(), clockwise: true)
clipPath.addLine(to: center)
clipPath.closeSubpath()
ctx.addPath(clipPath)
ctx.clip()
// 波浪液面
let wave = CGMutablePath()
let y = liquidRect.minY
wave.move(to: CGPoint(x: liquidRect.minX, y: y))
var x = liquidRect.minX
while x < liquidRect.maxX {
wave.addQuadCurve(to: CGPoint(x: x + 20, y: y),
control: CGPoint(x: x + 10, y: y + 6 * sin(phase + x * 0.05)))
x += 20
}
wave.addLine(to: CGPoint(x: liquidRect.maxX, y: liquidRect.maxY))
wave.addLine(to: CGPoint(x: liquidRect.minX, y: liquidRect.maxY))
wave.closeSubpath()
let colors = [UIColor(red: 1.0, green: 0.6, blue: 0.1, alpha: 1.0).cgColor,
UIColor(red: 0.9, green: 0.3, blue: 0.1, alpha: 1.0).cgColor]
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: colors as CFArray, locations: [0, 1])!
ctx.addPath(wave)
ctx.saveGState()
ctx.clip()
ctx.drawLinearGradient(gradient,
start: .zero,
end: CGPoint(x: 0, y: liquidRect.maxY),
options: [])
ctx.restoreGState()
}波浪路径里的phase参数是相位,后面做动画时会持续累加它,波浪就会向前滚动。渐变色让液体上部偏黄、下部偏红,比纯色填充更有层次。要注意每次调用clip()之前先saveGState(),绘制完restoreGState(),否则裁剪区域会一直生效,影响后面刻度的绘制。
三、动画驱动与低油量闪烁警告
draw方法只负责画一帧,动画需要外部驱动重绘。这里不推荐用Timer,因为它的精度和屏幕刷新不对齐。正确做法是CADisplayLink,它和屏幕刷新率同步,每帧回调一次。我们在回调里更新液位值和波浪相位,然后调用setNeedsDisplay()触发重绘。油量变化时不要直接跳到目标值,用缓动函数插值,让液位平滑地涨上去。
class FuelGaugeView: UIView {
var fuelLevel: CGFloat = 0.75 { didSet { startAnimaton(to: fuelLevel) } }
private var displayLevel: CGFloat = 0
private var phase: CGFloat = 0
private var displayLink: CADisplayLink?
func startAnimaton(to target: CGFloat) {
displayLink?.invalidate()
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.add(to: .main, forMode: .common)
// 用简单闭包保存目标值
animatingTarget = target
}
private var animatingTarget: CGFloat = 0
@objc private func tick() {
let diff = animatingTarget - displayLevel
displayLevel += diff * 0.08 // 缓动插值,越接近目标走得越慢
phase += 0.15 // 波浪滚动
if abs(diff) < 0.001 {
displayLevel = animatingTarget
// 油量稳定后可以停掉波浪动画省电,这里为了演示保留
}
setNeedsDisplay()
}
}低油量闪烁警告有两种实现思路。一种是纯Core Graphics方案:在tick回调里根据时间戳计算一个0到1的闪烁因子,比如blink = (sin(time * 4) + 1) / 2,绘制警告区域时把这个因子乘进颜色的alpha通道,红色区域就会规律地呼吸明暗。这种方案的好处是所有绘制逻辑集中在一处,状态管理简单。
另一种是Core Animation方案:给一个独立的CALayer设置opacity动画,用autoreverses和repeatCount设为无限循环,警告层就会自动闪烁,完全不需要手动刷新视图。它的优点是不占用CPU重绘开销,因为opacity动画由Core Animation渲染管线直接处理,即使主线程卡顿闪烁也不会停。缺点是警告层必须和CG绘制的表盘分层,需要额外管理一个子图层。
实际项目中建议这样取舍:如果仪表盘本来就每帧都在跑液位动画,直接用方案一,反正视图在重绘,顺手把alpha算进去零成本;如果液位是静态的、只有警告需要闪,用方案二的CAShapeLayer承载红色警告弧段,配合opacity动画,性能最优。
最后补一个容易被忽略的细节:车载场景经常跑在iPad或车机大屏上,draw方法里的半径计算要用min(bounds.width, bounds.height)做基准,同时重写layoutSubviews调用setNeedsDisplay(),保证旋转屏幕或分屏后表盘重绘到正确尺寸。把这些要点组合起来,一个专业感的燃油表组件就完成了,刻度清晰、液位有质感、警告醒目,而且全部基于系统框架,没有任何第三方依赖。
Core GraphicsiOS仪表盘绘制Quartz2D动画修改时间:2026-09-07 12:27:05