雷达扫描效果在蓝牙设备搜索、定位校准和安防监控等界面中经常出现。想在 iOS 上实现一个可交互的雷达图,完全不必依赖第三方图表库,使用 Core Graphics 配合 UIView 的 draw(_:) 方法就能完成。相比使用序列帧图片,自绘方案能够动态调整扫描角度、波纹数量和触摸判定范围,内存占用也更低。下面把实现拆成四个部分:雷达底图与网格、扇形渐变区域、波纹扩散动画以及触摸点坐标映射。整个过程只需要一个自定义 UIView,再通过定时器驱动重绘即可。

一、绘制雷达底图与同心圆网格
底图通常由中心点、若干同心圆和十字参考线组成。先确定视图中心与雷达半径,这两个值直接影响后续所有坐标计算。如果视图本身不是正方形,可以取 min(bounds.width, bounds.height) / 2 作为半径,让图形始终居中且不超出边界。自定义 UIView 时,建议把 radius 作为可配置属性,方便外部根据布局调整。
使用 CGContext 绘制同心圆时,可以按半径等分,例如把雷达半径分为 4 份,逐层画圆。线条宽度最好根据屏幕缩放因子设置,固定 0.5 点或 1 点在 Retina 屏幕上容易显得粗糙。十字参考线从中心向上下左右延伸,颜色采用低透明度白色或灰色,避免抢夺扫描高亮的视觉焦点。下面这段代码展示了网格和参考线的绘制过程。
private func drawRadarBackground(ctx: CGContext) {
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = radarRadius
ctx.saveGState()
ctx.setStrokeColor(UIColor(white: 0.5, alpha: 0.35).cgColor)
ctx.setLineWidth(0.6)
let gridCount: CGFloat = 4
for index in 1...Int(gridCount) {
let r = radius * CGFloat(index) / gridCount
ctx.addEllipse(in: CGRect(x: center.x - r, y: center.y - r, width: r * 2, height: r * 2))
}
ctx.move(to: CGPoint(x: center.x - radius, y: center.y))
ctx.addLine(to: CGPoint(x: center.x + radius, y: center.y))
ctx.move(to: CGPoint(x: center.x, y: center.y - radius))
ctx.addLine(to: CGPoint(x: center.x, y: center.y + radius))
ctx.strokePath()
ctx.restoreGState()
}
这段代码中,所有路径都汇入同一个上下文后统一执行 strokePath,比多次调用 stroke 更高效。实际使用时可以根据需要调整网格层数,比如 3 层更简洁,5 层则更密集。网格只是辅助定位,不必画得太抢眼。
二、扇形扫描区域与渐变填充
扇形扫描区域是雷达图的核心视觉元素。实现思路是先用路径裁剪出一个从当前旋转角向前展开的扇形,再在裁剪区域内填充径向渐变。这样渐变会被约束在扇形内部,不会溢出到其他区域。currentRotation 参数由定时器不断累加,通常以与屏幕刷新率相同的步长推进,形成持续扫描的效果。
渐变颜色建议使用三段式:从中心透明过渡到半透明,再到边缘高亮,这样形成雷达波发射后逐渐衰减的观感。核心 API 是 CGContext.clip 与 drawRadialGradient,后者从圆心向外辐射,正好符合雷达信号的传播方向。绘制完成后还需要补一条前沿边线,让扫描边界更清晰。扇形的角度大小可以用 sweepAngle 控制,典型值在 45 度到 90 度之间。
private func drawSweep(ctx: CGContext) {
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = radarRadius
let startAngle = currentRotation - sweepAngle
let endAngle = currentRotation
ctx.saveGState()
ctx.move(to: center)
ctx.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
ctx.closePath()
ctx.clip()
let colors = [
UIColor(red: 0.2, green: 0.8, blue: 0.4, alpha: 0.0).cgColor,
UIColor(red: 0.2, green: 0.8, blue: 0.4, alpha: 0.4).cgColor,
UIColor(red: 0.2, green: 0.8, blue: 0.4, alpha: 0.85).cgColor
] as CFArray
let locations: [CGFloat] = [0.0, 0.55, 1.0]
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: locations) else { return }
ctx.drawRadialGradient(gradient,
startCenter: center,
startRadius: 0,
endCenter: center,
endRadius: radius,
options: [.drawsAfterEndLocation])
ctx.restoreGState()
ctx.saveGState()
ctx.setStrokeColor(UIColor(red: 0.2, green: 0.8, blue: 0.4, alpha: 0.9).cgColor)
ctx.setLineWidth(1.5)
ctx.move(to: center)
let edgeX = center.x + cos(endAngle) * radius
let edgeY = center.y + sin(endAngle) * radius
ctx.addLine(to: CGPoint(x: edgeX, y: edgeY))
ctx.strokePath()
ctx.restoreGState()
}
如果希望扇形尾部过渡更柔和,可以增加一个从透明到半透明的起始位置,或者把 locations 的第一项从 0.0 调整到 0.1。混合模式也可以换成 .screen,在深色背景上会更亮,但需要根据整体配色做取舍。
三、波纹扩散动画的驱动与绘制
波纹扩散本质上是多个圆环从中心向外移动,同时透明度和线宽逐渐降低。可以定义一个简单的结构体保存每个波纹的当前半径、透明度和速度。定时器每帧更新这些值,当半径超过雷达最大半径时,将该波纹重置到初始位置,形成循环扩散。多个波纹错开初始半径,可以避免所有圆环同时出现或消失。
驱动动画优先使用 CADisplayLink,它能保证与屏幕刷新同步,动画更平滑。如果对帧率要求不高,Timer 也可以,但需要添加到主线程 RunLoop 的 common 模式下,避免用户在滑动列表时动画暂停。每一帧更新 currentRotation 和波纹参数,然后调用 setNeedsDisplay 触发重绘。由于 draw(_:) 默认会清空上一帧内容,不会出现残影,也无需手动清除。
struct RadarPulse {
var radius: CGFloat
var alpha: CGFloat
let speed: CGFloat
}
private var pulses: [RadarPulse] = [
RadarPulse(radius: 20, alpha: 0.6, speed: 1.2),
RadarPulse(radius: 80, alpha: 0.3, speed: 1.2)
]
private func updatePulses() {
for index in pulses.indices {
pulses[index].radius += pulses[index].speed
pulses[index].alpha = max(0, 0.6 - pulses[index].radius / radarRadius * 0.5)
if pulses[index].radius >= radarRadius {
pulses[index].radius = 0
pulses[index].alpha = 0.6
}
}
}
private func drawWaves(ctx: CGContext) {
let center = CGPoint(x: bounds.midX, y: bounds.midY)
for pulse in pulses {
ctx.saveGState()
ctx.setStrokeColor(UIColor(red: 0.2, green: 0.8, blue: 0.4, alpha: pulse.alpha).cgColor)
ctx.setLineWidth(1.2)
ctx.addEllipse(in: CGRect(x: center.x - pulse.radius,
y: center.y - pulse.radius,
width: pulse.radius * 2,
height: pulse.radius * 2))
ctx.strokePath()
ctx.restoreGState()
}
}
更新波纹时要注意,alpha 的计算应该基于当前半径与最大半径的比例,而不是固定递减,这样每个圆环在接近边界时都会自然淡出。速度值可以根据视图宽度做适配,保证在较小屏幕上圆环不会一帧就飞出边界。
四、触摸点坐标映射与命中检测
触摸事件传入的是 UIKit 坐标系,原点在视图左上角。雷达图使用极坐标更方便,所以第一步要把触摸点平移到以雷达中心为原点的坐标系,再计算距离和方位角。atan2(dy, dx) 返回 -π 到 π 之间的弧度,需要根据自己的角度定义做归一化。在 Core Graphics 的角度体系中,0 弧度在右侧,顺时针为正,但 UIKit 的 y 轴向下,实际视觉上看起来是逆时针从右向左变化,这一点在调试时要注意。
如果只是判断点是否在雷达半径内,用欧几里得距离即可。若要判断是否点中了某个方位上的目标,例如一个蓝牙设备标记,需要把触摸点的方位角与目标记录的方位角做差,同时考虑当前扫描旋转角。命中检测通常允许一个误差范围,比如角度差小于 0.15 弧度,距离差小于 15 点,这样手指触摸时的偏差不会影响操作。
对于可拖动的目标,还可以在 touchesMoved 里持续更新坐标,并把变化量反向应用到目标模型。下面代码演示了如何将触摸点转换为极坐标,并判断是否落在当前扫描扇形内。这个判断可以用于点击选中、高亮或弹出详情。
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let point = touch.location(in: self)
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let dx = point.x - center.x
let dy = point.y - center.y
let distance = sqrt(dx * dx + dy * dy)
var angle = atan2(dy, dx)
if distance <= radarRadius {
let originalAngle = angle - currentRotation
let normalizedAngle = originalAngle.truncatingRemainder(dividingBy: 2 * .pi)
let isInSweep = normalizedAngle > -sweepAngle && normalizedAngle <= 0
if isInSweep {
print("点击位于当前扫描扇形内,距离中心 \(distance) 点,方位角 \(angle)")
}
}
}
实际项目中,如果要让雷达图上的目标点响应点击,建议维护一个目标数组,每个目标包含极坐标距离和方位角。在 touchesEnded 里遍历数组,找到距离触摸点最近的元素即可。若目标点可以被拖动,则需要在手势移动时重新计算目标距离和方位角,并更新数组后调用 setNeedsDisplay。
Core GraphicsiOS雷达扫描图触摸点坐标映射修改时间:2026-09-26 19:40:12