简介:本资源面向iOS开发初学者与需要自定义图表效果的中级开发者,聚焦Swift中UIBezierPath绘制贝塞尔曲线的核心技巧。通过完整示例演示如何创建UIView子类、重写draw(_:)方法、构建三次与二次贝塞尔曲线路径,并调用stroke()或fill()完成平滑数据线绘制,帮助读者理解折线图平滑处理与自定义图表实现思路。压缩包共25个文件,包含6个m实现文件、3个h头文件、3个plist配置、2个storyboard与1个xib界面文件,以及png效果图、json数据、md说明和license等,整体约156KB,结构清晰便于对照学习。目前已有700人学习下载。读者可从中获得可直接运行的Xcode工程、贝塞尔曲线绘制图表的完整代码示例与项目配置参考,适合作为自定义图表绘制的入门实践素材。
1. 用贝塞尔曲线画图表:为什么我放弃了现成库,自己撸了一套
去年做一个健康管理类的 iOS 项目,需求里有一组体重趋势图、心率波动图,设计稿给的是那种平滑到几乎像手绘的曲线,不是折线图那种硬邦邦的拐点。我一开始图省事,直接上了常见的图表库,结果发现两个问题:一是曲线平滑度调不到设计要的效果,二是包体积和自定义成本都不划算。后来干脆用 UIBezierPath 自己画,一个下午搞定了核心逻辑,代码不到三百行,可控性反而更强。
Swift 贝塞尔曲线绘制图表这件事,本质上就是两件事:用 UIBezierPath 把数据点连成平滑曲线,再用 CAShapeLayer 把它渲染到屏幕上。听起来简单,但真正动手你会发现,平滑算法怎么选、控制点怎么算、坐标怎么映射、动画怎么做,每一步都有坑。这套方案适合两类人:一是想理解图表底层绘制原理的 iOS 开发者,二是不想引入重型图表库、只需要几种固定图表类型的中小型项目。下面我把自己踩过的路完整拆一遍。
2. 贝塞尔曲线画图表的数学底子:从数据点到平滑曲线
2.1 为什么折线不够用,三次贝塞尔才是正解
折线图的问题在于,数据点之间是直线连接,视觉上会有明显的棱角。当数据点密集时还好,一旦数据点稀疏,比如一天只记录一次体重,折线就会显得非常生硬。设计要的“平滑感”,本质上是用曲线代替直线段。
贝塞尔曲线分一次、二次、三次。一次就是直线,二次只有一个控制点,曲线形状受限,三次有两个控制点,可以做出非常自然的 S 形过渡。图表绘制里,三次贝塞尔曲线是标准选择。UIBezierPath 提供了addCurve(to:controlPoint1:controlPoint2:)方法,就是用来画三次贝塞尔曲线的。
核心问题变成:给定两个相邻数据点 P0 和 P1,怎么算出两个控制点 C1 和 C2,让曲线平滑地穿过所有点?
2.2 控制点计算:Catmull-Rom 样条转贝塞尔
最常见的做法是用 Catmull-Rom 样条的思想来推导控制点。Catmull-Rom 的特点是曲线一定穿过所有原始数据点,这正是图表需要的——每个数据点都必须准确落在曲线上。
具体算法是这样的:对于数据点序列 P[0], P[1], ..., P[n-1],对于第 i 段曲线(从 P[i] 到 P[i+1]),控制点按以下方式计算:
// 计算三次贝塞尔曲线的控制点 // points: 原始数据点数组 // 返回: 每段曲线的控制点对 func calculateControlPoints(points: [CGPoint]) -> [(CGPoint, CGPoint)] { var controlPoints: [(CGPoint, CGPoint)] = [] for i in 0..<points.count - 1 { let p0 = i > 0 ? points[i - 1] : points[i] let p1 = points[i] let p2 = points[i + 1] let p3 = i + 2 < points.count ? points[i + 2] : p2 // Catmull-Rom 转贝塞尔的控制点公式 // 张力系数 alpha 控制曲线松紧,0.5 是常用值 let alpha: CGFloat = 0.5 let d1 = CGPoint(x: p2.x - p0.x, y: p2.y - p0.y) let d2 = CGPoint(x: p3.x - p1.x, y: p3.y - p1.y) let cp1 = CGPoint( x: p1.x + d1.x * alpha / 3.0, y: p1.y + d1.y * alpha / 3.0 ) let cp2 = CGPoint( x: p2.x - d2.x * alpha / 3.0, y: p2.y - d2.y * alpha / 3.0 ) controlPoints.append((cp1, cp2)) } return controlPoints }这段代码的逻辑是:对每一段曲线,取前后各一个点作为参考,用差分的方式估算切线方向,再乘以张力系数 alpha 除以 3 得到控制点偏移量。alpha 越大曲线越“鼓”,越小越接近直线。0.5 是一个经验值,大部分场景下效果自然。
参数说明:points是已经转换到视图坐标系的数据点,alpha是张力系数,范围建议在 0.3 到 0.8 之间调整。如果曲线出现明显的过冲(overshoot),说明 alpha 太大了,往下调到 0.3 左右试试。
2.3 坐标映射:把业务数据翻译成屏幕坐标
数据点不能直接拿来画,因为业务数据的数值范围和屏幕坐标系的像素范围完全不是一回事。比如体重范围是 60 到 80 公斤,屏幕高度是 200 像素,你得建立一个映射关系。
// 将业务数据映射到视图坐标 // values: 原始数值数组 // rect: 绘图区域 // minValue/maxValue: 数据的最小值和最大值 func mapToPoints(values: [CGFloat], in rect: CGRect, minValue: CGFloat, maxValue: CGFloat) -> [CGPoint] { guard values.count > 1 else { return [] } let range = maxValue - minValue // 防止除零,同时留出上下边距 let safeRange = range == 0 ? 1 : range let topPadding: CGFloat = 20 let bottomPadding: CGFloat = 20 let drawableHeight = rect.height - topPadding - bottomPadding var points: [CGPoint] = [] let stepX = rect.width / CGFloat(values.count - 1) for (index, value) in values.enumerated() { let x = rect.minX + CGFloat(index) * stepX // 注意 Y 轴翻转:屏幕坐标原点在左上角 let normalized = (value - minValue) / safeRange let y = rect.minY + topPadding + drawableHeight * (1 - normalized) points.append(CGPoint(x: x, y: y)) } return points }这里有几个关键点。第一,Y 轴要翻转,因为 UIKit 的坐标原点在左上角,而业务数据通常是值越大越高。第二,safeRange处理了所有数据相同的情况,否则会除零崩溃。第三,上下留 padding 是为了防止曲线贴边,视觉上更舒服。
提示:如果数据中有异常值(比如某天体重记录错了),映射后曲线会出现剧烈抖动。常见做法是在映射前做一次数据清洗,把超出合理范围的值剔除或替换为前后均值。
3. 从 UIBezierPath 到 CAShapeLayer:渲染管线与动画落地
3.1 构建 Path 并交给 CAShapeLayer
有了控制点和映射后的坐标,接下来就是构建 UIBezierPath 并渲染。我一般不会直接在 drawRect 里画,而是用 CAShapeLayer,因为 layer 级别的操作更容易做动画和后续更新。
// 构建平滑曲线的 UIBezierPath func buildSmoothPath(from points: [CGPoint]) -> UIBezierPath { let path = UIBezierPath() guard points.count > 1 else { return path } path.move(to: points[0]) let controlPoints = calculateControlPoints(points: points) for i in 0..<points.count - 1 { let (cp1, cp2) = controlPoints[i] path.addCurve(to: points[i + 1], controlPoint1: cp1, controlPoint2: cp2) } return path } // 配置 CAShapeLayer func setupChartLayer(path: UIBezierPath, in view: UIView) -> CAShapeLayer { let shapeLayer = CAShapeLayer() shapeLayer.path = path.cgPath shapeLayer.fillColor = UIColor.clear.cgColor shapeLayer.strokeColor = UIColor.systemBlue.cgColor shapeLayer.lineWidth = 2.0 shapeLayer.lineCap = .round shapeLayer.lineJoin = .round // 可选:添加渐变填充 let gradientLayer = CAGradientLayer() gradientLayer.frame = view.bounds gradientLayer.colors = [ UIColor.systemBlue.withAlphaComponent(0.3).cgColor, UIColor.systemBlue.withAlphaComponent(0.0).cgColor ] gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) // 用 shapeLayer 做 mask 实现渐变填充 let fillPath = path.copy() as! UIBezierPath fillPath.addLine(to: CGPoint(x: points.last!.x, y: view.bounds.height)) fillPath.addLine(to: CGPoint(x: points.first!.x, y: view.bounds.height)) fillPath.close() let fillLayer = CAShapeLayer() fillLayer.path = fillPath.cgPath gradientLayer.mask = fillLayer view.layer.addSublayer(gradientLayer) view.layer.addSublayer(shapeLayer) return shapeLayer }逻辑说明:先构建曲线路径,然后创建 CAShapeLayer 来描边。渐变填充部分稍微绕一点——CAGradientLayer 本身不能直接沿曲线填充,所以用一条闭合路径(曲线 + 底部两条边)作为 mask 来裁剪渐变层。这样曲线下方就有渐变效果,视觉上比纯线条好看很多。
参数方面,lineWidth控制线宽,lineCap和lineJoin设为 round 可以让端点圆润。渐变的透明度从 0.3 到 0.0 是比较克制的做法,想要更明显可以调到 0.5。
3.2 绘制动画:让曲线“长出来”
静态图表能用,但加上绘制动画体验会好很多。CAShapeLayer 配合 CABasicAnimation 可以轻松实现“从左到右生长”的效果。
// 给曲线添加绘制动画 func animatePath(_ shapeLayer: CAShapeLayer, duration: CFTimeInterval = 1.0) { let animation = CABasicAnimation(keyPath: "strokeEnd") animation.fromValue = 0.0 animation.toValue = 1.0 animation.duration = duration animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) shapeLayer.strokeEnd = 1.0 shapeLayer.add(animation, forKey: "drawPath") }这个动画的原理是控制 strokeEnd 从 0 变到 1,layer 会沿着路径逐步绘制。timingFunction用 easeInEaseOut 让动画有加速减速的感觉,比线性自然。duration 一般设 0.8 到 1.5 秒,太短看不清,太长用户等得不耐烦。
注意:如果图表数据会动态更新,每次更新前要调用
shapeLayer.removeAllAnimations(),否则动画会叠加,出现闪烁。
3.3 数据点标记与交互命中
光有曲线还不够,用户往往需要看到具体的数据点。我一般会在每个数据点位置加一个小圆点,同时用 UITapGestureRecognizer 做点击命中检测。
// 添加数据点标记 func addDataPointMarkers(points: [CGPoint], to view: UIView) { for point in points { let dot = CAShapeLayer() let dotPath = UIBezierPath(arcCenter: point, radius: 3.0, startAngle: 0, endAngle: .pi * 2, clockwise: true) dot.path = dotPath.cgPath dot.fillColor = UIColor.white.cgColor dot.strokeColor = UIColor.systemBlue.cgColor dot.lineWidth = 1.5 view.layer.addSublayer(dot) } } // 点击命中检测 @objc func handleTap(_ gesture: UITapGestureRecognizer) { let tapLocation = gesture.location(in: self) let hitRadius: CGFloat = 20.0 for (index, point) in dataPoints.enumerated() { let distance = hypot(tapLocation.x - point.x, tapLocation.y - point.y) if distance < hitRadius { // 命中第 index 个数据点,展示详情 showDetail(at: index) return } } }命中检测用hypot算欧氏距离,比分别比较 x 和 y 的差值更准确。hitRadius设 20 是考虑到手指触摸的精度,太小了用户点不中。如果数据点很密集,可以适当减小到 15。
4. 避坑与排查:贝塞尔图表最容易翻车的五个地方
4.1 曲线过冲:控制点算出来曲线“飞”了
现象:曲线在某些数据点附近明显超出了数据范围,比如体重最大值是 80,曲线却画到了 85 的位置。
原因:Catmull-Rom 转贝塞尔时,如果相邻数据点的变化率差异很大,控制点会拉得很远,导致曲线过冲。alpha 系数越大越明显。
解决:把 alpha 从 0.5 降到 0.3 甚至 0.2,或者在计算控制点后加一个 clamp 操作,限制控制点的 y 值不超过数据范围。
4.2 数据点太少时曲线变形
现象:只有两三个数据点时,曲线形状很奇怪,甚至出现自交。
原因:Catmull-Rom 算法需要前后各一个参考点,边界点的处理是直接复制当前点,导致切线估计不准。
解决:数据点少于 4 个时,直接用直线连接,或者手动指定端点处的切线方向。我一般会在数据点少于 3 个时降级为折线。
4.3 坐标映射后曲线贴边或超出
现象:曲线顶部或底部被裁掉,或者紧贴视图边缘。
原因:映射时没有留 padding,或者 maxValue 和 minValue 取的是数据的精确极值。
解决:在极值基础上各留 10% 的余量。比如数据范围是 60 到 80,映射时用 58 到 82 作为边界。
4.4 动画与数据更新冲突
现象:数据刷新后曲线闪烁,或者动画从中间开始。
原因:旧的动画没有移除,新的 path 赋值后动画还在跑。
解决:更新数据前先removeAllAnimations(),然后重新设置 path 和动画。如果用的是同一个 shapeLayer,注意 path 的赋值顺序。
4.5 大量数据点导致性能下降
现象:数据点超过 500 个时,滑动卡顿,CPU 占用飙升。
原因:每个数据点都创建了 CAShapeLayer 做标记,layer 数量太多。
解决:数据点超过 200 个时,不显示单独的数据点标记,或者用 CAShapeLayer 合并绘制所有圆点。曲线本身用一条 path 绘制,性能没问题。
5. 进阶技巧:让图表真正好用的三个细节
5.1 用渐变描边替代纯色线条
纯色线条看久了单调,用 CAGradientLayer 做描边渐变会精致很多。做法是把渐变层的 mask 设为曲线的 stroke path,注意这里和填充渐变的区别——描边渐变需要把 shapeLayer 的 fillColor 设为 clear,strokeColor 设为渐变层的 mask。
// 渐变描边 func applyGradientStroke(to shapeLayer: CAShapeLayer, in view: UIView) { let gradient = CAGradientLayer() gradient.frame = view.bounds gradient.colors = [ UIColor.systemTeal.cgColor, UIColor.systemBlue.cgColor, UIColor.systemPurple.cgColor ] gradient.startPoint = CGPoint(x: 0, y: 0.5) gradient.endPoint = CGPoint(x: 1, y: 0.5) // 复制一份 path 作为 mask let maskLayer = CAShapeLayer() maskLayer.path = shapeLayer.path maskLayer.fillColor = UIColor.clear.cgColor maskLayer.strokeColor = UIColor.black.cgColor maskLayer.lineWidth = shapeLayer.lineWidth gradient.mask = maskLayer view.layer.addSublayer(gradient) shapeLayer.strokeColor = UIColor.clear.cgColor }5.2 多条曲线的层级管理
一个图表里画多条曲线时,zPosition 的管理很重要。后添加的 layer 默认在上面,但如果你希望某条曲线始终在最前面,显式设置zPosition更可靠。
| 曲线类型 | zPosition | 线宽 | 透明度 |
|---|---|---|---|
| 主曲线 | 3 | 2.5 | 1.0 |
| 对比曲线 | 2 | 1.5 | 0.6 |
| 参考线 | 1 | 1.0 | 0.3 |
5.3 用 Instruments 验证绘制性能
图表页面的性能问题往往出在离屏渲染和 layer 数量上。用 Xcode 的 Instruments 选 Core Animation 模板,勾选 Color Offscreen-Rendered Yellow 和 Color Blended Layers,跑一遍就能看到哪些区域在离屏渲染。贝塞尔曲线本身不会触发离屏渲染,但如果你加了 shadow 或者 cornerRadius 就要注意了。
我现在的习惯是:每次写完图表绘制代码,先用 Instruments 跑一遍,确认没有意外的离屏渲染和 layer 爆炸,再提交。这个习惯帮我省了好几次线上性能问题的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取