简介:这是一份Swift示例工程,面向iOS开发者,演示如何基于UIBezierPath绘制平滑的贝塞尔曲线图表。通过自定义UIView子类并重写draw方法,工程完整展示了二次与三次贝塞尔曲线的构建、控制点设置以及图表描边渲染的关键流程,适合需要优化折线图平滑度或实现自定义图表样式的开发场景。压缩包共25个文件,包含6个源代码文件、4张PNG图片、storyboard与xib界面布局、属性列表及Xcode工程配置,整体仅156KB,结构紧凑,便于快速查看并运行。已有700人学习下载,示例核心逻辑围绕move、addCurve与addQuadCurve展开,清楚解释了一、两个控制点如何影响曲线弧度,可帮助读者从代码层面理解贝塞尔曲线的几何本质,并将其灵活应用到股票K线、统计图表等场景中。工程不依赖第三方图表库,直接基于系统Core Graphics实现,学习成本低且便于二次修改,代码与界面可对照学习,尤其适合希望从原理层面掌握iOS绘图与图表实现的初中级开发者。
1. 从行情看板到项目骨架:Swift 里贝塞尔曲线是怎么变成图表的
做 Swift 开发的朋友应该都有过这种经历:产品抛过来一张设计稿,折线图上要有渐变面积、圆润曲线、入场动画,图表库要么定制不动,要么包体太大,最后只能自己动手。这套项目的核心,就是用 UIBezierPath 在 iOS 上从零绘制一套图表组件,没有依赖任何第三方绘图库。它把数据点换算成屏幕坐标,再用 Catmull-Rom 样条转三次贝塞尔生成平滑曲线,配合 CAShapeLayer 渲染,实现折线图、面积图、渐变填充以及 strokeEnd 入场动画。适合需要深度定制图表样式的 iOS 开发者,也适合刚接触 Core Graphics 但想在真实场景里练手的人,照着这份源码能看懂一条数据是怎么一步步变成屏幕上那条带弧度的曲线。
2. 整体架构与工程结构:三个层次把数据变成可动画的图层
2.1 三层架构:Model、Renderer、View 各管什么
拆这个 Swift 图表项目时,最先看到的是它的分层思路。虽然工程不大,但它没有把逻辑全塞进一个 UIView 里,而是按「数据模型 → 渲染器 → 视图容器」拆成了三层,每一层职责很单一。
Model 层定义的是图表的最小数据单元,比如一个 ChartPoint 结构体,包一个 x、y 坐标;Renderer 层负责把点数组变成 CGPath,这一层只和坐标打交道,不关心视图的 frame 和显示样式;View 层持有 CALayer 子类,把 Renderer 产出的 path 设置给 CAShapeLayer,并管理动画和触摸交互。
这种拆法最直接的收益是:想要换一种曲线算法,只需要改 Renderer;想要把折线图换成柱状图,只需要新增一个 Renderer,View 层几乎不用动。实际项目里图表最容易出问题的就是「样式和逻辑揉在一起改一处崩三处」,这里是给二维数据映射提供了一个清晰边界,比较适合做二次开发时参考。
2.2 工程目录与核心文件职责
项目源码里的文件划分很直白,打开后基本能猜到每个文件是干什么的,我整理了一份文件职责表,方便你下载后对照着看。
| 文件/目录 | 职责 |
|---|---|
| ChartPoint.swift | 图表数据模型,包含 x、y 值,支持 Codable 方便对接接口 |
| AxisRange.swift | 坐标轴值域,记录 x、y 的 min/max,用于归一化 |
| PathSmoother.swift | 贝塞尔曲线生成器,核心算法所在 |
| LineChartRenderer.swift | 将数据点映射为坐标并组装 UIBezierPath |
| ChartView.swift | 容器视图,持有 CAShapeLayer、CAGradientLayer,负责渲染与动画 |
| ChartTouchHandler.swift | 触摸命中检测,找最近数据点,画十字线 |
| Theme.swift | 颜色、线宽、动画时长等样式配置 |
这种把「算法」和「视图」分文件的组织方式,在源码阅读体验上很舒服。想调曲线平滑度,直接去 PathSmoother;想改动画节奏,去 ChartView 里找 CABasicAnimation;不用在几百行代码里反复上下滚动找某个方法。
2.3 渲染管线:一次 render() 里发生了什么
理解了文件分工,再顺一遍数据流就很清楚了。ChartView 对外暴露的接口很简单,开发者调 dataPoints 赋值,内部触发 render(),整个绘制过程大致分四步。
第一步,ChartView 把数据交给 LineChartRenderer,renderer 利用 AxisRange 做归一化,把业务值域(比如销售额 1000 ~ 5000)映射到 view 的 bounds 坐标;第二步,renderer 拿着屏幕坐标点数组调用 PathSmoother,生成曲线 UIBezierPath;第三步,ChartView 把 path 分别交给 lineLayer 和 areaMaskLayer,前者画线,后者配合 CAGradientLayer 做面积渐变;第四步,strokeEnd 动画从头到尾播一遍。
这四条链路是串行的,每一步的输出恰好是下一步的输入,调试的时候可以在 PathSmoother 里打点看看 path 有没有异常。这里有一个容易忽略的细节: ChartView 里所有 layer 的 path 都使用 bounds 坐标系,而不是 frame 坐标系,一旦搞混,图表整体偏移几乎是必然的,后面避坑章会专门展开。
3. 核心实现拆解:Catmull-Rom 转三次贝塞尔与坐标映射链路
3.1 平滑曲线算法:控制点从哪来
图表看起来「高级」还是「廉价」,很大程度上取决于曲线平滑度。直接拿 UIBezierPath 的 addLine 把所有点连起来,得到的是折线,硬邦邦的,设计稿肯定不认。这个项目的做法是 Catmull-Rom 样条转三次贝塞尔。
核心思想是:原始数据点作为样条通过点,控制点由相邻点之间的连线方向计算出来,这样曲线会像一条有弹性的橡皮筋一样,经过每一个数据点,又不会在拐点处生硬地折角。画出来的曲线比二次贝塞尔方案更圆润,也不会有 overshoot 过冲问题。看代码:
/// 把一组数据点转为平滑曲线 path func smoothPath(with points: [CGPoint]) -> UIBezierPath { let path = UIBezierPath() guard points.count > 1 else { return path } path.move(to: points[0]) for index in 1..<points.count { // 前一个点、当前起点、当前终点、后一个点 // 首尾用 clamp 方式复用最近的有效点,保证控制点不越界 let p0 = points[max(0, index - 2)] let p1 = points[index - 1] let p2 = points[index] let p3 = points[min(points.count - 1, index + 1)] // Catmull-Rom 转 Bezier 的控制点公式,1/6 是张力系数 let cp1 = CGPoint(x: p1.x + (p2.x - p0.x) / 6.0, y: p1.y + (p2.y - p0.y) / 6.0) let cp2 = CGPoint(x: p2.x - (p3.x - p1.x) / 6.0, y: p2.y - (p3.y - p1.y) / 6.0) path.addCurve(to: p2, controlPoint1: cp1, controlPoint2: cp2) } return path }这一段逻辑是整条曲线是否好看的命门。注意控制点 cp1 的 x 方向是 p2.x - p0.x,也就是用「后一个点减前一个点」来求当前点切线方向,而不是 p2.x - p1.x,这样可以避免曲线只沿着线段方向走而没有弧度。1/6 这个张力系数越小,曲线越贴近直线;越大,曲线越夸张。项目里默认 1/6,实测在点数密集时曲线依旧稳定,不容易出现波浪形抖动。
3.2 坐标换算:业务值域到屏幕坐标的映射
数据进图表之前都是业务值,比如「0.5 到 8.5」,而屏幕坐标是像素。这个项目单独抽了一个 AxisRange 来管理值域换算,代码也很好懂:
/// 把业务坐标点映射为屏幕坐标点 func plotPoint(_ value: ChartPoint, in bounds: CGRect, range: AxisRange) -> CGPoint { // x 方向线性映射 let x = bounds.minX + (value.x - range.minX) / (range.maxX - range.minX) * bounds.width // y 方向先归一化,再做翻转处理 let normalizedY = (value.y - range.minY) / (range.maxY - range.minY) let y = bounds.maxY - normalizedY * bounds.height return CGPoint(x: x, y: y) }参数说明:range.minX/maxX 是 x 轴值域,range.minY/maxY 是 y 轴值域,bounds 是图表的绘图区域。x 方向是普通线性映射,y 方向必须加一次翻转,因为 UIKit 坐标系的原点在左上角,y 轴向下增长,而业务图表习惯 y 轴向上增长。如果不做 bounds.maxY - 这一步,图表会上下颠倒,值越大画得越低,这个坑几乎所有人都会踩一次。
实际使用中,还需要对值域做 padding。比如 y 轴最大值如果和数据最大值完全相等,曲线就会顶到视图边缘,视觉上很难看。常规做法是给 range.maxY 放一个 10% 的余量,我一般会在 renderer 里做一层包装,计算出 minY 和 maxY 后各扩一点。
3.3 面积图与渐变填充
折线图只有一条线往往撑不起设计稿的质感,项目里做了面积图支持,而且填充的是渐变色。这里容易犯一个错误:CAGradientLayer 本身没有 path 属性,你不能直接把曲线 path 塞给一个渐变层然后让它只填充曲线下方区域。正确做法是用一个 CAShapeLayer 作为 gradientLayer 的 mask,代码如下:
// 构造面积闭合路径:从曲线终点连线到右下角、左下角、再回到起点 let areaPath = UIBezierPath(cgPath: linePath.cgPath) areaPath.addLine(to: CGPoint(x: points.last!.x, y: bounds.maxY)) areaPath.addLine(to: CGPoint(x: points.first!.x, y: bounds.maxY)) areaPath.close() // 渐变层只负责颜色,形状交给 mask 层 gradientLayer.frame = bounds gradientLayer.colors = [UIColor.systemBlue.withAlphaComponent(0.35).cgColor, UIColor.systemBlue.withAlphaComponent(0.05).cgColor] gradientLayer.mask = areaMaskLayer areaMaskLayer.path = areaPath.cgPath这段代码里的 areaMaskLayer 就是一个普通的 CAShapeLayer,它的填充色无所谓,真正起作用的是 path。gradientLayer 不加 path,而是把整个 bounds 的渐变色铺满,再让 mask 决定哪些区域可见。这样设计的好处是,渐变方向和颜色可以随时调整,而面积形状只由 mask 负责。有个细节需要注意:areaPath 闭合时,终点坐标 x 用 points.last 的 x,而不是 bounds.maxX,否则面积图会拖出一条水平尾巴到屏幕边缘,数据量小时特别明显。
3.4 strokeEnd 绘制动画与数据刷新
图表最直观的体验就是入场动画。项目用的是 CAShapeLayer 的 strokeEnd 属性,从 0 动画到 1,配合 timingFunction 实现「一笔画」的效果。核心动画配置如下:
let animation = CABasicAnimation(keyPath: "strokeEnd") animation.fromValue = 0 animation.toValue = 1 animation.duration = theme.animationDuration animation.timingFunction = CAMediaTimingFunction(name: .easeOut) animation.fillMode = .forwards animation.isRemovedOnCompletion = false lineLayer.add(animation, forKey: "lineDrawAnimation")参数说明:strokeEnd 对闭合路径无效,它只对未闭合的线段有视觉意义,所以面积图的 mask 不能靠 strokeEnd 做动画,项目里是给 areaMaskLayer 单独做了一个 opacity 动画配合。fillMode 用 .forwards 并且 isRemovedOnCompletion 设为 false,是为了让动画结束后路径保持最终形态,否则可能出现动画播完曲线突然消失的问题。
数据动态刷新和入场动画有点冲突。如果每次 setData 都重新加 strokeEnd 动画,用户会感觉图表一直在「重播」。通常做法是记录 hasRendered 状态,只有首次渲染时播放动画,后续 update 只更新 lineLayer.path,不触发动画。
3.5 触摸高亮与十字线的命中逻辑
图表只有展示不够,用户总想点某个点看具体数值。项目里 ChartTouchHandler 实现了简单的命中检测,核心逻辑是遍历所有数据点,找出离触摸位置最近的那个:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = touches.first, let renderer = renderer else { return } let loc = touch.location(in: self) // 在屏幕坐标上找距离触摸点最近的数据点 let nearest = renderer.plotPoints.min { hypot($0.x - loc.x, $0.y - loc.y) < hypot($1.x - loc.x, $1.y - loc.y) } guard let target = nearest else { return } // 把屏幕坐标反算回业务值,用于气泡展示 let value = renderer.value(for: target) delegate?.chartView(self, didHighlight: value) }命中逻辑并不复杂,但要注意两点。第一,比较距离时要用屏幕坐标,不能直接用业务值,因为 x 和 y 的缩放比例通常不同;第二,找最近点的时间复杂度是 O(n),数据量超过 200 个点时,建议改成按索引分段搜索或者提前把点按 x 排序,不然触摸时会有可感知的卡顿。气泡提示层放在 ChartView 之上,用原生的 UILabel 承载数值格式化的结果即可。
4. 运行与集成:把图表模块接进你的 Swift 工程
4.1 接入方式与运行环境
这套源码是纯 Swift 写的,UI 部分基于 UIKit,没有引入 CocoaPods 或 Carthage 之类的第三方依赖,这对接入非常友好。我实际试了两种方式都能跑起来。
第一种是直接把源码拖进自己的工程,需要注意勾选 Copy items if needed,并且确认 Targets 里勾选了你当前的 target。第二种是用 Swift Package Manager,把文件夹路径拖进 Package 依赖列表。工程要求 iOS 13.0 以上,Xcode 版本理论上 Swift 5 之后都能编译,我用的 Xcode 14 没有任何问题。
项目里的 Demo target 自带一个模拟数据源,编译运行后能直接看到一个带渐变面积的平滑曲线图,这是最快的验证方式。
4.2 初始化并渲染一条折线
接入的第一步是创建一个 ChartView 并给它赋值数据。这个项目的 View 使用方式和 UIKit 常规组件完全一致,代码可以放在 viewDidLoad 里:
let chart = ChartView(frame: CGRect(x: 16, y: 100, width: 340, height: 220)) chart.theme = Theme.makeDefault() chart.dataPoints = [ ChartPoint(x: 0, y: 12), ChartPoint(x: 1, y: 18), ChartPoint(x: 2, y: 9), ChartPoint(x: 3, y: 24), ChartPoint(x: 4, y: 20), ] view.addSubview(chart)dataPoints 是 [ChartPoint] 数组,ChartPoint 的 x、y 都是 CGFloat。赋值之后 ChartView 的 didSet 方法会自动触发 render(),不需要手动调用刷新方法。如果你想让图表自适应父视图宽度,可以直接用 Auto Layout 约束,注意要保证高度约束不为零,否则 bounds 是零矩形,render 计算出来的坐标全是 NaN。
4.3 常用配置项说明
Theme.swift 里集中管理图表的视觉参数,改样式不需要动 View 代码。我把常用配置项整理成表,方便你对照调整。
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| lineColor | UIColor | systemBlue | 折线颜色 |
| lineWidth | CGFloat | 2.0 | 折线宽度,太细会显得单薄 |
| curveTension | CGFloat | 1/6 | 张力系数,控制曲线弯曲程度 |
| showAreaGradient | Bool | true | 是否显示面积渐变 |
| showPoints | Bool | true | 是否绘制数据点圆点 |
| animationDuration | TimeInterval | 0.8 | 入场动画时长 |
| axisColor | UIColor | systemGray5 | 网格线颜色 |
| numberOfYAxisLines | Int | 4 | y 轴网格线数量 |
curveTension 是调样式时最常用的参数,它直接传给 PathSmoother,数值越小曲线越像折线。我习惯把数值调大 30% 对比一次,看到曲线明显拱起时,就能直观理解张力系数的意思。showPoints 打开时,renderer 还会为每个数据点画一个小圆点,内部实现是额外生成一个圆形的 UIBezierPath 填充到 pointLayer。
4.4 动态更新数据
图表在实际场景里不会只画一次,接口数据几秒刷新一次是常态。项目支持直接覆盖 dataPoints 来更新,内部会比对数据数量:如果数量一致,只更新 lineLayer 和 areaMaskLayer 的 path,不加动画;如果数量变了,会重新走渲染管线。示例代码:
// 模拟定时器刷新数据 Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in let newPoints = (0..<20).map { index in ChartPoint(x: index, y: randomValue()) } chart.dataPoints = newPoints }这里需要注意数据点数量变化时,曲线动画不会重播,但面积 mask 会瞬间填充。如果你希望在数据刷新时也有一段过渡效果,可以在 render() 里对 areaMaskLayer 做一次淡入淡出动画,项目源码里提供了一种可选实现。
5. 避坑与排查:贝塞尔图表绘制的六个翻车现场
5.1 曲线上下颠倒,整条超出容器
现象:数据值越大,曲线反而越低,甚至直接跑到视图外面。
原因:UIKit 的坐标原点在左上角,y 轴向下增长。直接做归一化后乘以 bounds.height,没有做翻转,导致 y 方向反了。
解决:映射公式里必须用 bounds.maxY 减去归一化 y 乘以高度。确认代码里是let y = bounds.maxY - normalizedY * bounds.height,而不是normalizedY * bounds.height。顺手可以打印第一个点的屏幕坐标,如果 y 显示为 bounds.maxY 附近,说明翻转正确。
5.2 点数密集时触摸滑动掉帧严重
现象:数据量到两三百个点,手指滑动时图表区域的响应逻辑明显卡顿,甚至滚动列表都受影响。
原因:触摸命中是用min遍历所有点做距离比较,O(n) 的复杂度在密集数据下被放大,而且每帧都触发 setNeedsDisplay 重绘。
解决:命中搜索改成按 x 索引二分查找,先找触摸点最近的 index,再在前后各取几个点比较距离;同时渲染改成只更新 CAShapeLayer 的 path,不要调用整个视图的 draw 方法。这个项目里的 ChartTouchHandler 只优化了索引二分,后续二次开发可以继续加缓存。
5.3 strokeEnd 动画播完,曲线突然消失
现象:动画播放过程中曲线正常,动画结束的一瞬间线条消失,只剩数据点悬空。
原因:CABasicAnimation 默认在动画结束后会把 layer 属性恢复成动画之前的值,相当于 strokeEnd 从 1 回退到初始状态。如果设置里没有填充模式,就会出现「放完即消失」的现象。
解决:给动画设置fillMode = .forwards并且isRemovedOnCompletion = false。更好的做法是在动画代理回调里,直接把 lineLayer.strokeEnd 设置为 1,再把动画移除,这样不会影响后续的 path 更新。
5.4 图层坐标系错位,曲线整体偏移
现象:曲线形状对,但位置整体向右下方偏移,或者只显示了一部分。
原因:CAShapeLayer 的 path 用的是 layer 的 bounds 坐标,而 ChartView 在 initWithFrame 之后如果 frame 变了,layer 的 bounds 也可能改变。如果 path 里的坐标用的是 view.bounds 但设置给了一个 bounds 不同的 layer,就会出现系统性偏移。
解决:渲染前统一把绘图区域对齐到 layer.bounds,或者干脆在 layoutSubviews 里重新生成 path。我这里强制约定:所有 path 生成时只使用 bounds,frame 只负责视图布局,两者不混用。
5.5 面积渐变完全不显示
现象:折线正常,但曲线下方没有渐变色块。
原因:CAGradientLayer 没有 path 属性,直接把贝塞尔曲线 path 赋值给 gradientLayer 是没有效果的,必须通过 mask 来裁切。
解决:新建一个 CAShapeLayer,把面积闭合路径设置给它的 path,再把它赋给 gradientLayer.mask。另外记得给 gradientLayer 设置 frame,mask 的 path 坐标要跟 gradientLayer 的 bounds 一致。
5.6 旋转屏幕后图表错位或面积 mask 残留
现象:横竖屏切换后,曲线和图表的相对位置变了,面积渐变停留在旧的位置。
原因:视图布局变化后,bounds 更新,但渲染代码没有重新执行,而 layer 的 frame 被系统自动调整,path 和 mask 就不匹配了。
解决:在 ChartView 的 layoutSubviews 里调用 render(),并且先判断 bounds 是否变化,避免每次 setNeedsLayout 都全量重绘。实测在列表 cell 里复用时这个坑尤其明显,复用视图的 bounds 变了却不重绘,经常导致图表显示错乱。
6. 二次开发:把固定折线图改造成可扩展的图表引擎
6.1 用协议抽象渲染器
项目的折线渲染实现得很扎实,但你不可能永远只画折线。做二次开发时,最值得先做的一件事是抽象渲染协议,让 ChartView 不再强依赖 LineChartRenderer。我一般会加一个轻量协议:
protocol ChartRenderable: AnyObject { associatedtype DataModel func render(data: [DataModel], in bounds: CGRect) -> UIBezierPath }下一步把 ChartView 的 renderer 类型从具体的 LineChartRenderer 改成任意遵守这个协议的类型,数据模型不统一的问题可以用泛型或者类型擦除解决。这样折线图、柱状图、饼图各写各的 renderer,互不污染,图表组件就从「一个功能」长成了「一个小引擎」。
6.2 加一种新图表:柱状图渲染器实战
协议抽象完,实践一把。实现柱状图渲染器只需要把曲线 path 换成矩形 path,复用坐标映射逻辑:
struct BarChartRenderer: ChartRenderable { func render(data: [ChartPoint], in bounds: CGRect) -> UIBezierPath { let path = UIBezierPath() let barWidth = bounds.width / CGFloat(data.count) * 0.6 for point in data { let plot = plotPoint(point, in: bounds) let rect = CGRect(x: plot.x - barWidth / 2, y: plot.y, width: barWidth, height: bounds.maxY - plot.y) // 高度小于 1 的条视觉上是空,至少给 1pt UIBezierPath(roundedRect: rect, cornerRadius: 2).append(path) } return path } }这里把 bars 的 path 直接 append 进同一个 UIBezierPath,让 layer 只管理一个 path,渲染性能更好。高度最小值 1pt 是一个真实场景的细节,数据为 0 时矩形高度为 0,用户看到的是那条基线,会有「没数据」的错觉,补齐视觉下限是更稳妥的做法。
6.3 交互增强:触摸点高亮与数据提示
项目自带十字线高亮,但二次开发里一般还要加一个数据气泡。思路是在 ChartView 上持有一个 UILabel,触摸命中后把屏幕坐标转换到气泡 center,注意气泡宽度会溢出屏幕边界,要做一个 clamp 操作,让气泡始终保持在视图内部。这套代码大约三十行,核心还是项目里已有的 hitTest 逻辑。
6.4 性能兜底:只重绘变化区域
图表数据频繁刷新后,最容易被忽视的是性能。虽然 CAShapeLayer 更新 path 已经比 drawRect 快很多,但数据量大了仍然会有浪费。我建议在 renderer 里算一个 incrementalIndex,只更新最后几个变化的点对应的那一段曲线,而不是每次全量重建 path。实现上可以将 path 拆成多个分段 UIBezierPath,每段对应几个数据点,刷新时只替换变更段。这个优化在实时监控类图表里效果尤其明显,CPU 占用量能降一截。
拆完这套源码再看图表开发,最核心的门道还是坐标映射、曲线算法和图层结构。我自己后来接新图表需求时,都会强制把 PathSmoother 在点数为 1、2、10、100 的 case 下各跑一遍,确认曲线不会因为点太少而变形、也不会因为点太多而抖动,再交给视图层。这个习惯帮我躲掉过很多次「看着没啥问题,一上线就露出怪形状」的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取