
简介面向有一定Swift基础的iOS开发者这份贝塞尔曲线绘制图表示例工程基于UIBezierPath实现平滑数据曲线适合需要自定义图表、折线图或动画效果的中级开发者参考。压缩包内共25个文件包含6个源码文件、4张png图片素材、3个plist配置、3个头文件以及storyboard/xib界面文件与Xcode工程配置等整体体积仅156KB结构清晰便于快速导入分析。示例工程演示了move(to:)、addCurve(to:controlPoint1:controlPoint2:)和addQuadCurve(to:controlPoint:)的典型用法重点展示如何重写UIView的draw(_:)方法获取绘图上下文设置画笔颜色与线宽并最终通过stroke()绘制出圆滑连续的图表曲线。已有700人学习下载通过阅读源码可掌握控制点选取、曲线拼接与路径构建等实用技巧理解Core Graphics底层的绘图机制从而在实际项目中灵活改造出折线图、曲线图或动态动画提升自定义绘图能力也为后续图形开发打下坚实基础。1. 反直觉的结论iOS 没给你现成图表库反而给了你最大的自由度第一次接到用贝塞尔曲线绘制图表的需求时我的第一反应是找现成的图表框架。可当需求细化成“曲线要平滑、要带渐变面积、入场动画要卡着设计稿的节奏走”之后现成库反而成了黑匣子——改一个拐弯处的曲率都要翻半天源码遇到点小问题就怀疑是不是自己姿势不对。这份“swift-iOS贝塞尔曲线绘制图表”项目不依赖任何第三方图表框架用系统自带的UIBezierPath与CAShapeLayer从零画出一张可交互的曲线图。它适合两类人一类是刚开始接触Swift绘图、想搞清楚贝塞尔曲线控制点到底怎么影响曲线形态的新手另一类是已经在图表库的定制限制里折腾了很久、想把绘制主动权抓回手里的开发者。2. 技术选型与工程全景UIBezierPath、CAShapeLayer、Core Graphics 三件套2.1 这份项目覆盖了图表链路中的哪些环节在 Swift开发-图表(Chart) 这个细分方向里最常见的坑是把简单需求做复杂只想画一条曲线最后却塞进来一个重型的图表引擎。这份项目走的是另一条路——它用系统原生的 UIBezierPath 曲线绘制覆盖了一条完整的数据可视化链路数据预处理、值域归一化、坐标映射、贝塞尔插值平滑、CAShapeLayer 渲染、strokeEnd 入场动画以及触摸交互层的十字参考线与数值气泡。从功能面来看这份资源交付的并不只是一个“能画曲线”的 Demo而是一整套图表基础设施。具体来说做了四层事情。第一层是坐标映射原始业务数据可能是千奇百怪的量纲和数值范围需要被统一映射到视图的绘制区域内同时考虑上、下、左、右的边距留白。第二层是曲线生成直接在折线点之间画直线是最容易的但视觉上不够优雅于是用三次贝塞尔曲线在相邻数据点之间做平滑过渡。第三层是渲染与动画路径构建完成后通过 CAShapeLayer 渲染用 strokeEnd 做曲线从无到有的绘制动画。第四层是交互触摸图表时能命中最近的数据点显示十字参考线和高亮气泡。2.2 为什么是 UIBezierPath CAShapeLayer而不是别的这里花点篇幅讲清楚技术选型的逻辑因为后面所有代码都是围绕这个决定展开的。在 iOS 平台画一条平滑曲线常见方案有三个直接在 draw(_:) 里用 Core Graphics 写路径用 UIBezierPath 构建路径再交给 CAShapeLayer 渲染或者引入第三方图表库的封装组件。我一般会把第一种方案排除掉。draw(:) 在主线程同步执行数据点只要超过几十个每次重绘都要重新计算路径界面滚动时稍有不慎就会掉帧。而 UIBezierPath 本身只是一个几何容器它不管渲染真正干活的是它底下的 CGPath。把 CGPath 赋给 CAShapeLayer 之后渲染就交给了 GPU路径更新可以独立于主线程进行。更关键的是 CAShapeLayer 暴露了 strokeStart 和 strokeEnd 两个属性配合 CABasicAnimation 可以做出曲线生长的动画这一点在 draw(:) 体系里实现起来要绕很大的弯。至于第三方图表库适合那种“能用就行”的场景。但一旦发现需要定制曲线阻尼、需要自定义渐变填充、需要坐标刻度跟着数据动态变化库的封装反而成了束缚。这份项目手写绘制层的价值在于每一段路径、每一个控制点都是你自己的代码想怎么改就怎么改。这种自由度在图表需求频繁变动的项目中是很实际的优势。2.3 目录结构七份源码文件如何分工整个项目的源码组织非常扁平没有复杂的架构分层但每一份文件的职责极为清楚BezierChartDemo/ ├── AppDelegate.swift // 应用入口启动后加载图表页 ├── ViewController.swift // 图表容器的宿主控制器 └── ChartView/ ├── LineChartView.swift // 图表主视图承接触摸事件、组织渲染 ├── ChartDataModel.swift // 数据模型输入的分组与归一化 ├── ChartCoordinateMapper.swift // 坐标映射业务值 - 视图坐标 ├── ChartPathBuilder.swift // 贝塞尔路径构建插值算法与闭合路径 ├── ChartConfig.swift // 全局配置颜色、线宽、留白等 └── ChartTouchHandler.swift // 触摸回调与高亮逻辑主要看 ChartView 这个目录。LineChartView 是整个图表的门面对外暴露数据源和配色接口内部直接引用其他四个文件。ChartDataModel 负责把输入数组整理成模型对象比如每个数据点在数组中的下标、原始数值、归一化之后的相对位置。ChartCoordinateMapper 做的事情很纯粹就是值域与像素域的换算。ChartPathBuilder 专门干贝塞尔路径的活输入一批映射好的坐标点输出一条平滑的 UIBezierPath。ChartTouchHandler 处理所有手势相关的东西包括最近点命中与十字线绘制。2.4 设计取舍这份项目刻意不做什么理解一个项目最快的方式除了看它做了什么还要看它没做什么。这份项目刻意没有去做坐标轴刻度的自动排版也没有做多序列图表的图例系统更没有引入 Core Plot 这类重型渲染引擎。这些省略不是偷懒而是把复杂度挡在核心链路之外。坐标轴刻度在真实业务里往往需要和具体文案、单位、小数位强绑定最合适的位置是交给调用方定制多序列则意味着数据源结构要变成二维数组会污染路径构建器的纯粹性。保留最小核心把扩展点留给二次开发这个设计取向贯穿了整份源码。3. 核心模块拆解从数据数组到平滑曲线的四层转换链路3.1 坐标映射层把业务数值翻译成视图坐标任何绘图代码的第一步都是先在脑子里建立坐标系。UIKit 的坐标系统以视图左上角为原点、向下为 y 轴正方向而图表通常习惯“数值越大越靠上”所以在映射时必须对 y 轴做一次翻转。ChartCoordinateMapper 的核心代码通常长这样struct ChartCoordinateMapper { var contentRect: CGRect // 绘制区域的矩形 var topInset: CGFloat // 顶部留白 var bottomInset: CGFloat // 底部留白 var minValue: CGFloat // 纵轴最小值 var maxValue: CGFloat // 纵轴最大值 func xPosition(at index: Int, totalCount: Int) - CGFloat { guard totalCount 1 else { return contentRect.midX } let step contentRect.width / CGFloat(totalCount - 1) return contentRect.minX step * CGFloat(index) } func yPosition(for value: CGFloat) - CGFloat { let range maxValue - minValue guard range 0 else { return contentRect.midY } let ratio (value - minValue) / range let availableHeight contentRect.height - topInset - bottomInset return contentRect.minY topInset availableHeight * (1 - ratio) } }xPosition 采用的是等距横轴布局每个数据点在水平方向上均匀分布间距是绘制区宽度除以点数减一。yPosition 做的是线性归一化把数值域映射到绘制区高度后再做一次翻转。关键参数是 topInset 和 bottomInset——很多刚上手的人把它们设成 0结果曲线顶点直接顶着视图边缘数值气泡画出来也被裁掉一半。实际项目中顶部留白通常至少留出气泡的高度再加几个点底部留白用来放时间刻度标签。这段代码里有两个容易被忽略的防御逻辑。一个是 totalCount 为 1 时 x 取中位防止除零另一个是 maxValue 等于 minValue 时 y 取中位。这两个边界情况在真实数据里一定会出现比如设备静置时的心率曲线一整条平线。没有防御逻辑的话函数会返回 NaN曲线直接消失或者画出一堆乱线。3.2 曲线生成层Catmull-Rom 算法转三次贝塞尔在数据点之间的直线上折腾半天需求方一句“我要平滑曲线”就能推翻所有工作量。最朴素的平滑想法是在相邻点之间用二次贝塞尔控制点取前一个点的位置——这样出来的曲线每段之间在交点处有折角视觉上仍不自然。想要真正的光滑过渡需要在两个连续点之间引入三次贝塞尔且前一段的终点控制点和后一段的起点控制点共享同一条切线方向。实现上最常见的做法是把 Catmull-Rom 样条曲线转成三次贝塞尔代码长这样func buildSmoothCurve(points: [CGPoint]) - UIBezierPath { let path UIBezierPath() guard points.count 1 else { return path } path.move(to: points[0]) for i in 0..(points.count - 1) { let current points[i] let next points[i 1] let prev points[max(0, i - 1)] let nextNext points[min(points.count - 1, i 2)] let cp1 CGPoint( x: current.x (next.x - prev.x) / 6.0, y: current.y (next.y - prev.y) / 6.0 ) let cp2 CGPoint( x: next.x - (nextNext.x - current.x) / 6.0, y: next.y - (nextNext.y - current.y) / 6.0 ) path.addCurve(to: next, controlPoint1: cp1, controlPoint2: cp2) } return path }这里最关键的是 6.0 这个除数它控制曲线的张力。除数越小控制点离原始点越远曲线越膨胀除数越大曲线越贴近折线。6.0 是 Catmull-Rom 转贝塞尔的标准参数实际项目中我一般会把它放进 ChartConfig 里而不是写死在代码中这样调整曲率时不需要动核心逻辑。原理上每段三次贝塞尔需要两个控制点。当前段起点 current 处的切线与 prev 到 next 的方向一致取三分之一的距离作为 cp1终点 next 处的切线与 current 到 nextNext 的方向一致反向取三分之一距离作为 cp2。这样各段连接点处的切线方向连续曲线看起来就是一个整体不会出现折角。边界段要注意处理第一个点的 prev 取自己最后一个点的 nextNext 取自己否则曲线会因为没有邻居点而算出错误的方向。3.3 渲染与动画层strokeEnd 控制入场动画路径构建完成之后下一步就是上屏。在 draw(_:) 里画虽然合法但我更倾向把路径交给 CAShapeLayer文章开头也解释过原因——layer 的 path 可动画strokeEnd 动画不需要手动维护计时器。渲染与动画的典型写法是let lineLayer CAShapeLayer() lineLayer.path path.cgPath lineLayer.strokeColor UIColor.systemBlue.cgColor lineLayer.fillColor UIColor.clear.cgColor lineLayer.lineWidth config.lineWidth lineLayer.lineJoin .round lineLayer.lineCap .round lineLayer.strokeEnd 0 let drawAnimation CABasicAnimation(keyPath: strokeEnd) drawAnimation.fromValue 0 drawAnimation.toValue 1 drawAnimation.duration config.animationDuration drawAnimation.timingFunction CAMediaTimingFunction(name: .easeOut) lineLayer.add(drawAnimation, forKey: chartLineDraw) layer.addSublayer(lineLayer)需要注意 strokeEnd 的状态管理。常见做法是在动画开始前把 layer 的 strokeEnd 设为 0toValue 设为 1这样 CA 能在 presentation layer 上做插值动画结束后要把 model 层的 strokeEnd 也赋成 1否则曲线会跳回起点。另一种做法是设置 isRemovedOnCompletion false 配合 fillMode .forwards这样虽然能停在终点但后续如果想要重播动画残留的 presentation 状态会造成首帧闪烁。把图表做成面积图时需要在曲线路径上额外 append 一段到右下角再回到左下角的封闭线然后设置 fillColor 和 fillRule。如果要做渐变填充常见做法是加一层 CAGradientLayer用封闭路径作为它的 mask让渐变方向和颜色都独立配置。曲线层和渐变层分开放置后续调整透明度或者整体隐藏时不需要改动路径构建逻辑。3.4 交互层触摸高亮、十字参考线与数值气泡图表不应该是死的。业务场景里用户看到曲线之后的第一反应是点击某个点查看具体数值。交互层要解决两个问题命中最近数据点的算法以及高亮视觉的绘制。触摸处理的骨架大致如下override func touchesBegan(_ touches: SetUITouch, with event: UIEvent?) { guard let location touches.first?.location(in: self) else { return } let index nearestDataIndex(toX: location.x) showHighlight(at: index) } private func nearestDataIndex(toX targetX: CGFloat) - Int { let step drawingWidth / CGFloat(dataPoints.count - 1) let raw (targetX - drawingRect.minX) / step let clamped max(0, min(CGFloat(dataPoints.count - 1), round(raw))) return Int(clamped) } private func showHighlight(at index: Int) { let point mapper.point(for: index, value: dataPoints[index]) indicatorLayer.position point bubbleHandler.update(value: dataPoints[index], at: point) }nearestDataIndex 做的是把触摸点坐标除以每列的步长得到浮点下标再用 round 取最近整数下标。这个算法比遍历所有数据点求距离快很多数据量达到几千个点时优势明显。边界情况处理得很直接——触摸点在图左边缘外侧时下标为负直接 clamp 成 0右边缘同理 clamp 成最后一个点的下标。十字参考线和高亮气泡我一般不用 UIView 来做而是再叠加两个 CAShapeLayer 或 CALayer。手指滑动时这两个视图会高频移动用 UIView 会有额外的 layout 开销。气泡里的文本可以用 Core Graphics 画或者预建一个隐藏的 UILabel 随手势更新 frame。点数不超过上百个时这种方式的性能完全够用。4. 运行与调试在 Xcode 最小工程里把图表跑起来4.1 新建工程与文件引入第一步是在 Xcode 里新建一个 iOS App 工程模板用 App 即可语言选 Swift。Interface 选 Storyboard 还是 SwiftUI 都行——这份项目的核心是 UIKit 视图SwiftUI 工程里用 UIViewRepresentable 包一层即可。第二步是把 ChartView 目录下的六个文件拖进工程。在 Project Navigator 里右键添加文件勾选 Copy items if needed确认 Target 正确不然文件会因为没加进编译列表而报未定义的错误。第三步是在 ViewController 的 viewDidLoad 里创建一个 LineChartView 实例赋一份测试数据作为 subview 加进层级class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let sampleData: [CGFloat] [12, 18, 15, 22, 19, 28, 24] let chartView LineChartView(frame: CGRect(x: 16, y: 120, width: 343, height: 220)) chartView.data sampleData view.addSubview(chartView) } }这段代码里没有显式调用 setNeedsDisplay——因为 LineChartView 在 data 的 didSet 里会自己触发布局和重绘。数据赋值即刷新这个约定对后续的动态更新至关重要。运行模拟器就能看到一条平滑曲线从左往右画出来。默认配置下曲线是系统蓝线宽 2.5动画时长 0.8 秒带轻微的圆头描边。4.2 核心参数表调在哪里、影响什么要上手调参先认识 ChartConfig 这个文件它集中管理了所有影响视觉效果的常量。附一张参数参考表参数默认值作用调整方向lineWidth2.5曲线描边宽度加大显粗壮调小显精致animationDuration0.8入场绘图动画时长大于 1 秒会明显拖节奏topInset24顶部留白不够则气泡被裁剪bottomInset20底部留白不够则刻度文字溢出curveTension6.0曲线张力参数减小更鼓增大更贴折线pointRadius4.0数据点高亮半径只影响高亮视觉gradientOpacity0.3面积渐变透明度需要配合封闭路径使用参数表里最容易被忽略的是 curveTension。数据噪声大、点的疏密不均匀时张力设太小会画出明显过冲的震荡曲线设太大等于回到折线图。我一般先从 8 开始调看曲线走向自然再一点一点往下减直到出现自己不能接受的过冲为止。调整完参数后清除模拟器上残留的 layer 缓存再重新运行某些热重载残留会掩盖真实的渲染问题。4.3 跑起来以后如何验证曲线不是“看起来对”判断一条曲线画得对不对不能只靠肉眼。肉眼容易被骗特别是当数据点之间的走向恰好与曲线一致时。我习惯在调试阶段做三件验证第一在 ChartCoordinateMapper 里打印每个数据点映射后的坐标确认 x 方向等距、y 方向单调第二在 ChartPathBuilder 里检查每段的起始点和终结点确保相邻两段首尾相连第三最直接的手段在模拟器里触摸每个数据点观察高亮气泡显示的数字是否与原始数据一致。第三个验证尤其重要它能一同暴露两个隐藏问题坐标映射错位、触摸命中偏移。常见做法是在 ChartTouchHandler 里写一个 debug 断言当命中的 index 对应的数据点与气泡上显示的数字不一致时直接在控制台输出坐标信息。这样调试数据不是来自肉眼而是来自几何计算准确率完全不同。这套验证看着像玄学实际上每一条都是血泪经验换来的。5. 避坑指南贝塞尔绘图高发的四类翻车现场与排查5.1 曲线首尾变形、脱离数据点走向现象输入一组单调递增的数据画出来的曲线开头却明显向下弯结尾向上翘看起来像蛇在爬行而不是一条平滑的趋势线。原因在贝塞尔插值算法中第一个数据点没有前驱节点最后一个数据点没有后继节点。如果不做处理计算切线时会用到不存在的邻点得到极端的方向导数导致曲线在首尾过冲。数据点数量少、少于 10 个的时候这个问题尤为明显。解决在 ChartPathBuilder 里做边界保护。常见做法是把第一个点的 prev 设为自己、最后一个点的 nextNext 设为自己这样切线方向不至于失去约束。如果仍然过冲可以对首尾两段使用线性插值或者增加一个首尾阻尼配置把首尾两段的控制点往点本身拉近。5.2 动画不执行或 strokeEnd 方向反转现象曲线要么完全没有动画直接蹦出来要么表现为从右往左反向消失。原因前者通常是因为 strokeEnd 在动画前被赋成了 1动画的 fromValue 却是 0播放时 layer 认为自己已经到了终点视觉上就像没有动画后者是因为 model 层是 1动画从 0 到 1 播放完成后系统在动画结束瞬间把 model 值写回来表现成曲线被擦掉。解决添加动画之前强制把 layer.strokeEnd 设为 0动画 toValue 设为 1并在 add 之后写入持久化赋值 strokeEnd 1。统一封装一个 replay 函数每次调用先移除旧动画再添加新动画避免重复调用时动画叠加。5.3 旋转屏幕、横竖屏切换后图形错位现象图表在竖屏下显示正常转向横屏后曲线变形、气泡位置不对、网格线出现在另一个位置。原因LineChartView 的 layoutSubviews 里重复调用了坐标映射和路径重建但之前创建的 CAShapeLayer 还带着旧 frame 的路径。常见的错误是把绘制放到 viewDidLoad 或 init 里只执行一次没有在布局变化时同步刷新 layer。解决在自定义 UIView 里重写 layoutSubviews先调用 super再根据当前 bounds 重新走一遍从坐标映射到路径重建再到 layer 更新的完整流程。为省性能可以加一个 dirty 标志只在布局确实变化时才重建路径否则直接跳过。5.4 触摸命中漂移、高亮插到了错误的数据点现象明明点在图表的第 3 个点附近气泡却显示第 5 个点的数值。数据点密集时尤为明显。原因nearestDataIndex 计算时使用的绘制矩形与当前实际绘制区域不一致。常见原因包括记入了导航栏或安全区域的高度、未考虑 LineChartView 在父视图中的偏移或者 layoutMargins 变化后没有更新绘制区域。解决把绘制区域的计算统一收敛到 ChartCoordinateMapper 的初始化中每次 layoutSubviews 时重新生成 mapper确保触摸命中和绘制用的是同一个坐标系。再在 ChartTouchHandler 里加一个断言让调试时能立刻发现计算差异。6. 二次开发沉淀把图表视图抽成可配置的独立组件如果只是把代码跑通这份源码的价值其实只发挥了一半。项目交付的是怎么画出来但真正让下一个项目省时间的是怎么把它抽出来复用。我的建议是围绕三个改造点做配置协议化、数据源协议化、目录独立化。第一点把 ChartConfig 从硬编码常量改造成协议加默认实现。以 curveTension 为例不同业务对曲线张力要求完全不同——销售曲线要显得平缓监控数据要忠实还原毛刺。给每个图表实例传入自己的配置而不是改全局 shared 配置能避免多页面间互相影响。第二点把数据源从 [CGFloat] 提升为一个轻量协议。定义 ChartDataSource 协议要求提供 points 数组、纵轴最小值和最大值。LineChartView 内部只和协议打交道不关心数据来自网络、数据库还是写死的测试数组。带来一个直接好处单元测试时可以注入假数据源不需要真的发起网络请求。第三点把 ChartView 目录整体复制成独立文件夹与业务 Controller 分开存放。新项目第一次接入时只需把文件夹拖进工程把测试数据换成真实业务数据就行。如果是实时滚动的场景比如展示 CPU 占用率或股票分时图建议在 LineChartView 里增加一个 update(data:) 接口内部用增量渲染代替全量重绘每次只追加末端新路径裁掉旧路径头部。增量渲染能明显降低 CPU 开销避免每帧都重建整条路径。需要注意的一个惯性思维是入口动画只加一次。我在改造版本里把 strokeEnd 动画绑定到配置开关同时支持从窗口右侧进入的滚动动画二者通过环境变量区分。从那以后我每次新增图表样式都强制走一遍同样的流程先在 ChartConfig 追加新参数再写一个最小复现 Demo 验证渲染正确最后才抄进业务页面。这套习惯帮我避开了很多把简单 Demo 改复杂时才会暴露的坑。希望帮到你。本文还有配套的精品资源点击获取