在iOS开发中,展示数据趋势最常见的方式就是折线图。虽然市面上有Charts等第三方库,但依赖第三方库往往带来包体积膨胀、定制困难等问题。其实Core Graphics配合UIKit的绘制机制,完全可以实现一套轻量、可控的折线图控件。本文围绕数据点坐标映射、贝塞尔曲线平滑处理以及触摸交点高亮三个核心环节,详细讲解实现思路和代码。

一、数据模型与坐标映射:把业务数据变成屏幕坐标
绘制折线图的第一步是定义数据模型。最简单的做法是用一个结构体数组表示每个数据点,包含业务值和可选的横轴标签。业务数据的取值范围和视图尺寸完全是两个坐标系,必须先做归一化映射,否则画出来的图形会跑出视图边界。
struct ChartPoint {
let label: String
let value: Double
}
struct LineChartRenderer {
var points: [ChartPoint] = []
var minValue: Double = 0
var maxValue: Double = 0
// 把业务值转换为视图坐标系中的y值
// 注意:UIKit默认坐标系y轴向下,需要翻转
func screenY(for value: Double, in rect: CGRect) -> CGFloat {
let padding: CGFloat = 20
guard maxValue > minValue else { return rect.midY }
let ratio = (value - minValue) / (maxValue - minValue)
return rect.height - padding - CGFloat(ratio) * (rect.height - padding * 2)
}
func screenX(at index: Int, count: Int, in rect: CGRect) -> CGFloat {
guard count > 1 else { return rect.midX }
let usable = rect.width - 40
return 20 + CGFloat(index) / CGFloat(count - 1) * usable
}
}
这里有一个容易踩的坑:Core Graphics原始坐标系的y轴是向上的,而UIKit对UIView的绘制上下文做了翻转,默认y轴向下。上面的代码在计算y值时用总高度减去归一化结果,就是为了适配这种翻转关系。如果不做处理,画出来的图形会上下颠倒,初学者十有八九会在这里卡住。
横坐标的分配策略也值得斟酌。如果数据点是等间隔的(比如每小时一个采样点),按索引均分即可;如果是时间序列且间隔不均匀,就应该按真实时间差计算横坐标,否则曲线的走势会被压扁或拉伸,误导使用者对趋势的判断。
二、直线连接与贝塞尔曲线平滑的两种方案
基础折线图的绘制非常直观:遍历所有映射后的坐标点,调用addLine逐段连接,最后stroke出路径。但直线连接在数据点较多时会显得生硬,视觉上缺乏流畅感。这时就需要用贝塞尔曲线对折线做平滑处理。
第一种是二次曲线(QuadCurve)方案。它的原理是以上一个数据点作为控制点,先画到相邻两点的中点,再以中点作为过渡继续绘制,最终曲线一定经过每个数据点。实现代码如下:
func quadPath(points: [CGPoint]) -> UIBezierPath {
let path = UIBezierPath()
guard points.count > 1 else { return path }
path.move(to: points[0])
for i in 1..<points.count {
let prev = points[i - 1]
let current = points[i]
let mid = CGPoint(x: (prev.x + current.x) / 2,
y: (prev.y + current.y) / 2)
path.addQuadCurve(to: mid, control: prev)
}
path.addLine(to: points[points.count - 1])
return path
}
QuadCurve的优点是计算量小、实现简单;缺点是曲率变化不够自然,在数据剧烈波动处会出现过渡生硬的问题。更高级的做法是使用三次贝塞尔曲线,为每一段曲线分别计算起点控制点和终点控制点。常见的计算方式是让控制点沿着前后数据点连线的方向偏移,偏移系数通常取0.3到0.5之间。系数越大曲线越圆润,但也越容易产生过冲,也就是曲线的波峰超过实际数据点的极值,造成数据失真。折中的办法是在极值点处强制控制点保持水平,保证曲线不会越过真实的峰值。
// 计算三次贝塞尔曲线的控制点,smoothness为平滑系数,建议取0.3
func controlPoints(p0: CGPoint, p1: CGPoint, p2: CGPoint,
p3: CGPoint, smoothness: CGFloat) -> (CGPoint, CGPoint) {
let center = CGPoint(x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2)
let dx = center.x - (p0.x + p3.x) / 2
let dy = center.y - (p0.y + p3.y) / 2
var c1 = CGPoint(x: p1.x + (p2.x - p0.x) * smoothness,
y: p1.y + dy * smoothness)
var c2 = CGPoint(x: p2.x - (p3.x - p1.x) * smoothness,
y: p2.y - dy * smoothness)
// 极值点处强制水平,防止曲线过冲越过真实峰值
if (p1.y < p0.y && p1.y < p2.y) || (p1.y > p0.y && p1.y > p2.y) {
c1.y = p1.y
}
if (p2.y < p1.y && p2.y < p3.y) || (p2.y > p1.y && p2.y > p3.y) {
c2.y = p2.y
}
return (c1, c2)
}
两种方案怎么选?如果只是做简单的趋势展示,QuadCurve完全够用且性能更好;如果是金融类、监控类的图表,对数据准确性要求高,建议使用带极值保护的三次曲线方案,或者干脆提供一个可配置的平滑开关,让调用方按场景自行决定。
三、触摸交点高亮:最近点查找与命中判定
交互是折线图的灵魂。用户手指在图表上滑动时,需要实时找到距离手指最近的曲线点,并绘制高亮圆点和数值提示。实现思路是重写touchesBegan、touchesMoved和touchesEnded,在其中把触摸坐标与所有数据点做距离比较。
var highlightIndex: Int?
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
// 只比较横向距离,避免曲线陡峭时命中困难
var nearest = 0
var minDistance = CGFloat.greatestFiniteMagnitude
for (index, point) in screenPoints.enumerated() {
let d = abs(point.x - location.x)
if d < minDistance {
minDistance = d
nearest = index
}
}
highlightIndex = nearest
setNeedsDisplay()
}
查找算法的细节决定了手感。如果用欧氏距离(同时比较x和y),手指在曲线下方滑动时可能命中错误的数据点,因为陡峭段的y差值会主导距离计算。只比较横向距离是更符合直觉的做法:用户感知的是手指对齐了哪个数据列,而不是精确贴在曲线上。对于数据点极多的高频场景,线性查找可以优化为二分查找,因为数据点的x坐标天然有序。
命中之后的高亮绘制建议放在draw(_ rect:)里统一处理,而不是额外叠加一个子视图。高亮层通常包含三部分:曲线上的实心圆点、圆点外圈的半透明光晕、以及顶部的数值气泡。气泡可以用UIBezierPath画一个带小三角的圆角矩形,文字部分用NSAttributedString的draw方法渲染。手指抬起时可以延迟几百毫秒再清除高亮,避免快速滑动时高亮反复闪烁。
四、性能优化与绘制细节
如果图表需要频繁刷新(比如实时数据流),务必把坐标映射的结果缓存起来,只在数据变化时重新计算,draw方法内只做纯绘制操作,避免每次重绘都重复遍历数据做浮点运算。
另一个提升滚动性能的思路是把网格线、渐变填充、曲线拆分到不同的CAShapeLayer或独立视图,图层内容不变时不会触发重绘。数据量达到数千点时,还可以做抽稀处理,保留极值点、丢弃中间的平稳点,肉眼几乎看不出差异,但绘制耗时能下降一个数量级。
最后,绘制曲线时注意线帽和拐角设置:setLineCap(.round)和setLineJoin(.round)能让曲线端点和转折处更圆润;如果要给曲线下方加渐变填充,记得用曲线路径闭合到底边后再clip填充区域,并使用saveGState与restoreGState保护图形状态,避免污染后续绘制。掌握这些细节后,一个不依赖任何第三方库的折线图控件就成型了。
Core Graphics折线图贝塞尔曲线修改时间:2026-09-09 20:17:07