导读:本期聚焦于夏天宇创作的《如何用Core Graphics实现iOS雷达扫描图?扇形渐变、波纹扩散与触摸点坐标映射详解》,敬请观看详情。想在iOS应用里实现类似雷达扫描的动画效果,通常不需要依赖第三方图表库,Core Graphics已经提供了足够的绘图能力。本文围绕扇形扫描区域、渐变填充、波纹扩散和触摸坐标映射四个核心点展开。绘制时先建立以视图中心为原点的极坐标系,用CGContext绘制同心圆与十字参考线,再通过径向渐变生成扇形高亮。波纹扩散依赖定时器驱动半径与透明度变化,每一帧调用setNeedsDisplay重新渲染。触摸部分需要把UIKit坐标转换为极坐标,计算点到圆心的距离和方位角,进而判断是否落在目标扇区或波纹范围内。整体方案基于CALayer与draw(_:)完成,不引入额外依赖,便于控制动画节奏与交互反馈。文中代码使用Swift编写,继承UIView并重写绘图方法即可集成。

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

如何用Core Graphics实现iOS雷达扫描图?扇形渐变、波纹扩散与触摸点坐标映射详解

一、绘制雷达底图与同心圆网格

底图通常由中心点、若干同心圆和十字参考线组成。先确定视图中心与雷达半径,这两个值直接影响后续所有坐标计算。如果视图本身不是正方形,可以取 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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0926/62265.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。