简介:本资源面向iOS开发初学者与需要自定义图表效果的中级开发者,聚焦Swift中UIBezierPath绘制贝塞尔曲线的核心技巧。通过一个完整的Xcode工程示例,演示如何重写draw(_:)方法、构建三次与二次贝塞尔曲线路径,并将折线数据平滑为美观的数据线,解决系统图表库难以满足个性化视觉需求的问题。压缩包共25个文件,约156KB,包含6个Swift源码文件、3个Objective-C头文件、3个plist配置、2个storyboard与1个xib界面文件,以及工程配置、测试目录和README说明,结构完整可直接运行。目前已有700余人学习下载。读者可从中掌握move(to:)、addCurve与addQuadCurve等关键API的实际用法,理解控制点对曲线形态的影响,并参考示例代码将理论知识落地为可复用的自定义图表视图,适合作为贝塞尔曲线入门的练手项目。1. 用贝塞尔曲线画图表,为什么比直接堆 View 更值得学如果你写过 iOS 图表,大概率经历过这种纠结:用第三方库吧,定制化一改就崩;自己用UIStackView堆柱子吧,圆角、渐变、动画全得靠嵌套,性能还拉胯。这个项目走的是另一条路——直接用UIBezierPath和CAShapeLayer从零画折线图、柱状图和饼图。它适合两类人:一是想搞懂图表底层渲染原理的 Swift 开发者,二是接私活时需要轻量级、无依赖图表方案的人。整个项目不依赖任何第三方库,纯 UIKit + Core Graphics,代码量可控,改起来心里有底。下面我从目录结构开始,把这套东西拆开讲清楚。2. 项目结构与渲染管线:从数据到屏幕像素的完整链路拿到源码先别急着跑,花五分钟把目录翻一遍,后面改代码能省一半时间。这个项目的组织方式很典型,按职责分层,没有花哨的架构,但每一层都卡在关键位置上。2.1 目录分层与文件职责常见的目录结构是这样的:BezierChart/ ├── App/ │ ├── AppDelegate.swift │ └── SceneDelegate.swift ├── Chart/ │ ├── Base/ │ │ ├── ChartView.swift // 图表基类,持有数据源和样式配置 │ │ └── ChartConfig.swift // 颜色、线宽、动画时长等参数 │ ├── Line/ │ │ ├── LineChartView.swift // 折线图主视图 │ │ └── LineChartRenderer.swift // 贝塞尔路径构建与绘制 │ ├── Bar/ │ │ ├── BarChartView.swift │ │ └── BarChartRenderer.swift │ └── Pie/ │ ├── PieChartView.swift │ └── PieChartRenderer.swift ├── Model/ │ └── ChartDataPoint.swift // 数据模型:value、label、color ├── Extension/ │ ├── CGFloat+Extension.swift // 角度转弧度、数值映射 │ └── UIColor+Extension.swift // 渐变色生成 └── Resource/ └── Assets.xcassetsChartView是基类,定义了dataPoints、config和reloadData()三个核心接口。子类只负责实现drawChart(in rect: CGRect)方法,把具体的路径构建逻辑交给对应的 Renderer。这种拆分的好处是:样式配置和数据源在 View 层,路径计算在 Renderer 层,两边互不干扰。你想换一套配色,只动ChartConfig;想改折线平滑算法,只动LineChartRenderer。2.2 渲染管线:CAShapeLayer 才是真正干活的很多人以为图表是draw(_ rect:)画出来的,其实这个项目用的是CAShapeLayer方案。draw(_:)在主线程同步执行,数据量大时直接卡 UI;CAShapeLayer把路径提交给 GPU 合成,还能挂动画。核心链路是这样的:// ChartView.swift 基类中的关键初始化 override init(frame: CGRect) { super.init(frame: frame) // 关闭隐式动画,避免布局时闪烁 layer.masksToBounds = true // 每个图表持有一个 shapeLayer 用于路径绘制 shapeLayer.fillColor = UIColor.clear.cgColor shapeLayer.lineCap = .round shapeLayer.lineJoin = .round layer.addSublayer(shapeLayer) } // 子类重写此方法,由 Renderer 返回构建好的路径 func drawChart(in rect: CGRect) { fatalError("子类必须实现") } override func layoutSubviews() { super.layoutSubviews() shapeLayer.frame = bounds // 布局变化时重新计算路径 drawChart(in: bounds) }这里有几个参数值得注意:lineCap设为.round让折线端点圆润,lineJoin设为.round避免尖角。masksToBounds开启后,超出边界的路径会被裁掉,防止图表溢出。layoutSubviews里重新调用drawChart是必须的,否则旋转屏幕后路径不会更新。2.3 数据映射:把业务数值翻译成坐标点图表的核心就一件事:把[100, 200, 150]这样的数值,映射到屏幕上的CGPoint。这个项目在CGFloat+Extension.swift里封装了映射函数:extension CGFloat { /// 将数值从数据区间映射到绘图区间 /// - Parameters: /// - dataMin: 数据最小值 /// - dataMax: 数据最大值 /// - drawMin: 绘图区最小值(通常是 0) /// - drawMax: 绘图区最大值(通常是视图高度) func map(from dataMin: CGFloat, _ dataMax: CGFloat, to drawMin: CGFloat, _ drawMax: CGFloat) -> CGFloat { // 防止除以零导致 NaN guard dataMax - dataMin != 0 else { return drawMin } let ratio = (self - dataMin) / (dataMax - dataMin) return drawMin + ratio * (drawMax - drawMin) } }调用时,Y 轴需要翻转,因为 UIKit 坐标系原点在左上角:let y = value.map(from: minValue, maxValue, to: chartHeight, 0)minValue和maxValue通常取数据集的极值,再留 10% 的 padding,避免折线贴顶或贴底。这个 padding 系数在ChartConfig里叫yAxisPaddingRatio,默认 0.1,改大改小直接影响图表的视觉呼吸感。3. 折线图核心实现:贝塞尔路径构建与动画落地折线图是三个图表里最复杂的,因为它涉及路径平滑、渐变填充和入场动画。把这部分吃透,柱状图和饼图基本就是降维打击。3.1 构建折线路径:从 move 到 addLineLineChartRenderer的核心方法是buildLinePath(with points: [CGPoint]):func buildLinePath(with points: [CGPoint]) -> UIBezierPath { let path = UIBezierPath() guard points.count > 1 else { return path } // 起点:移动到第一个数据点 path.move(to: points[0]) // 依次连接后续点 for i in 1..<points.count { path.addLine(to: points[i]) } return path }这段代码本身不复杂,但有两个细节容易翻车。第一,points.count必须大于 1,否则move(to:)之后没有线段,路径为空。第二,如果数据点少于 2 个,应该直接返回空路径而不是崩溃。我见过有人在points[0]处直接取下标,数据源为空时闪退,血泪经验。3.2 平滑曲线:用三次贝塞尔替代直线直线连接看起来太硬,很多图表需要平滑曲线。项目里提供了smoothFactor参数,开启后用三次贝塞尔曲线连接:func buildSmoothPath(with points: [CGPoint], smoothness: CGFloat = 0.3) -> 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 curr = points[i] // 计算控制点偏移量 let dx = (curr.x - prev.x) * smoothness let dy = (curr.y - prev.y) * smoothness // 两个控制点分别位于前后点的水平偏移位置 let cp1 = CGPoint(x: prev.x + dx, y: prev.y + dy) let cp2 = CGPoint(x: curr.x - dx, y: curr.y - dy) path.addCurve(to: curr, controlPoint1: cp1, controlPoint2: cp2) } return path }smoothness控制曲线弯曲程度,0 就是直线,0.3 左右比较自然,超过 0.5 会出现明显的过冲。控制点的计算逻辑是:第一个控制点从前一个点沿切线方向偏移,第二个控制点从当前点反向偏移。这样能保证曲线在数据点处平滑过渡,不会出现尖角。3.3 渐变填充:CAGradientLayer 与路径的配合折线下方通常需要渐变填充,做法是把折线路径闭合到 X 轴,然后交给CAGradientLayer做遮罩:func applyGradientFill(to path: UIBezierPath, in rect: CGRect) { // 复制路径并闭合到底部 let fillPath = path.copy() as! UIBezierPath fillPath.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) fillPath.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) fillPath.close() // 创建渐变层 let gradientLayer = CAGradientLayer() gradientLayer.frame = rect gradientLayer.colors = [ config.lineColor.withAlphaComponent(0.4).cgColor, config.lineColor.withAlphaComponent(0.0).cgColor ] gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) // 用路径做遮罩 let maskLayer = CAShapeLayer() maskLayer.path = fillPath.cgPath gradientLayer.mask = maskLayer layer.insertSublayer(gradientLayer, at: 0) }startPoint和endPoint用单位坐标,(0.5, 0) 到 (0.5, 1) 表示从上到下渐变。withAlphaComponent控制透明度,顶部 0.4、底部 0 是最常见的配置。注意insertSublayer(at: 0)把渐变层插到最底层,否则会盖住折线。3.4 入场动画:strokeEnd 从 0 到 1折线图的入场动画用strokeEnd实现,这是CAShapeLayer的专属属性:func animateLineDrawing() { // 先设置路径,再执行动画 shapeLayer.path = linePath.cgPath let animation = CABasicAnimation(keyPath: "strokeEnd") animation.fromValue = 0 animation.toValue = 1 animation.duration = config.animationDuration animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) shapeLayer.add(animation, forKey: "lineDraw") }animationDuration默认 0.8 秒,在ChartConfig里可调。timingFunction用.easeInEaseOut比线性更自然。有个坑:如果shapeLayer.path没提前设置,动画会从空路径开始,看起来像闪一下。所以顺序必须是先设 path,再加动画。4. 柱状图与饼图:复用路径思路解决不同几何问题折线图搞定后,柱状图和饼图的实现思路是相通的——都是构建UIBezierPath,交给CAShapeLayer渲染。区别在于几何计算。4.1 柱状图:圆角矩形与分组布局柱状图的核心是计算每根柱子的 frame,然后用圆角矩形路径绘制:func buildBarPath(at index: Int, value: CGFloat, in rect: CGRect) -> UIBezierPath { let barCount = CGFloat(dataPoints.count) let spacing: CGFloat = config.barSpacing // 柱间距 let totalSpacing = spacing * (barCount + 1) let barWidth = (rect.width - totalSpacing) / barCount let x = spacing + (barWidth + spacing) * CGFloat(index) let barHeight = value.map(from: 0, maxValue, to: 0, rect.height) let barRect = CGRect(x: x, y: rect.height - barHeight, width: barWidth, height: barHeight) // 圆角只加在顶部两个角 let path = UIBezierPath(roundedRect: barRect, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: config.barCornerRadius, height: config.barCornerRadius)) return path }barSpacing默认 8pt,barCornerRadius默认 4pt。byRoundingCorners只让顶部圆角,底部保持直角,这是柱状图的标准视觉。如果数据量大,barWidth会变得很窄,需要设置最小宽度,否则柱子细成一条线。4.2 饼图:扇形路径与角度计算饼图用addArc构建扇形,关键是起始角和结束角的计算:func buildPieSector(startAngle: CGFloat, endAngle: CGFloat, radius: CGFloat, center: CGPoint) -> UIBezierPath { let path = UIBezierPath() // 从圆心移动到起始角边缘 path.move(to: center) // 画弧线到结束角 path.addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true) // 闭合回圆心 path.close() return path }角度用弧度制,CGFloat.pi * 2是整圆。每个扇形的角度 =(value / totalValue) * 2 * pi。注意clockwise参数在 UIKit 坐标系里是反的,传true实际是逆时针方向,这个玄学坑我踩过不止一次。解决办法是统一用true,然后通过调整startAngle和endAngle的顺序来控制方向。4.3 点击选中:hitTest 与路径判断饼图需要响应点击,判断点是否在扇形内。项目里用UIBezierPath.contains(_:)做命中检测:override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { guard let point = touches.first?.location(in: self) else { return } for (index, path) in sectorPaths.enumerated() { if path.contains(point) { // 选中该扇形,放大 1.05 倍 highlightSector(at: index) delegate?.chartView(self, didSelectItemAt: index) break } } }sectorPaths是渲染时缓存的路径数组,避免每次点击重新计算。highlightSector通过transform缩放实现放大效果,配合CABasicAnimation做过渡。注意path.contains对闭合路径有效,如果路径没close(),判断会不准。5. 避坑与常见问题:那些文档不会告诉你的细节这一章记录几个实际开发中高频翻车的点,每个都按「现象 → 原因 → 解决」来说。5.1 图表不显示或只显示一部分现象:reloadData()调了,数据也传了,但屏幕上什么都没有,或者只画了前几个点。原因:shapeLayer.frame没有跟随bounds更新。CAShapeLayer的frame默认是 zero,路径坐标是相对于 layer 自身的,如果 frame 不对,路径就画到屏幕外了。解决:在layoutSubviews里显式设置shapeLayer.frame = bounds,并且确保drawChart在 frame 设置之后调用。如果用了 Auto Layout,layoutSubviews可能被多次调用,加个if oldBounds != bounds的判断避免重复计算。5.2 动画结束后路径消失现象:入场动画播完,折线不见了。原因:CABasicAnimation默认在动画结束后移除,shapeLayer的strokeEnd回到初始值 0。解决:设置animation.isRemovedOnCompletion = false和animation.fillMode = .forwards,让动画停留在最终状态。或者更稳妥的做法是,动画结束后手动设置shapeLayer.strokeEnd = 1。5.3 数据为空时崩溃现象:接口返回空数组,App 直接闪退。原因:points[0]或dataPoints.max()在空数组上取下标或求极值。解决:所有路径构建方法开头加guard !points.isEmpty else { return },maxValue计算时用dataPoints.map { $0.value }.max() ?? 1,给个默认值兜底。5.4 渐变填充颜色不对现象:设置了渐变色,但显示出来是纯色或者颜色错位。原因:CAGradientLayer的frame和mask路径坐标系不一致,或者colors数组里的 CGColor 没有正确转换。解决:确保gradientLayer.frame和maskLayer.path基于同一个坐标系。UIColor.cgColor在暗黑模式下会动态变化,如果需要固定颜色,用UIColor(red:green:blue:alpha:)显式指定。5.5 大量数据时滚动卡顿现象:数据点超过 500 个,图表滑动明显掉帧。原因:每次layoutSubviews都重新构建完整路径,CPU 计算量大。解决:缓存路径,只在数据源变化时重建。可以用dataVersion标记,数据没变就直接复用shapeLayer.path。另外,折线图数据点超过 1000 时,考虑采样降频,每 N 个点取一个,视觉上几乎看不出差别。6. 二次开发:把这套图表改造成你要的样子拿到源码后,最常改的三个地方是:样式配置、数据源适配和新增图表类型。我一般会先跑通 Demo,然后按下面的顺序动手。6.1 样式配置:集中管理而不是散落各处ChartConfig是个 struct,所有可调参数都在里面:struct ChartConfig { var lineColor: UIColor = .systemBlue var lineWidth: CGFloat = 2.0 var animationDuration: TimeInterval = 0.8 var yAxisPaddingRatio: CGFloat = 0.1 var barSpacing: CGFloat = 8.0 var barCornerRadius: CGFloat = 4.0 var showGrid: Bool = true var gridColor: UIColor = .systemGray5 }改配色只动lineColor,改动画时长只动animationDuration。如果项目需要多套主题,可以扩展一个ChartTheme协议,让ChartConfig从主题初始化。这样切换暗黑模式或品牌色时,不用翻遍每个 Renderer。6.2 数据源适配:从 JSON 到 ChartDataPoint实际项目里数据通常来自网络,需要一层转换:struct ChartDataPoint { let value: CGFloat let label: String? let color: UIColor? } // 从接口响应映射 func mapResponse(_ items: [[String: Any]]) -> [ChartDataPoint] { return items.compactMap { dict in guard let value = dict["value"] as? CGFloat else { return nil } return ChartDataPoint(value: value, label: dict["label"] as? String, color: nil) } }compactMap自动过滤掉解析失败的项,避免脏数据导致崩溃。如果接口返回的是字符串数值,记得用CGFloat(string)转换并处理 nil。6.3 新增图表类型:以雷达图为例新增图表遵循同样的模式:继承ChartView,实现drawChart,把路径构建逻辑放到独立的 Renderer。以雷达图为例,核心是计算多边形顶点:func buildRadarPath(values: [CGFloat], center: CGPoint, radius: CGFloat) -> UIBezierPath { let path = UIBezierPath() let angleStep = CGFloat.pi * 2 / CGFloat(values.count) for (index, value) in values.enumerated() { let angle = angleStep * CGFloat(index) - CGFloat.pi / 2 let r = radius * (value / maxValue) let point = CGPoint(x: center.x + cos(angle) * r, y: center.y + sin(angle) * r) index == 0 ? path.move(to: point) : path.addLine(to: point) } path.close() return path }角度减pi/2是为了让第一个顶点朝上。maxValue取所有维度中的最大值,保证图形比例协调。雷达图的网格线是同心多边形,用同样的角度计算,只是半径按比例缩小。6.4 验证方法:用预览和单元测试兜底改完代码后,我习惯用 SwiftUI Preview 快速看效果:struct LineChartView_Previews: PreviewProvider { static var previews: some View { LineChartView(dataPoints: [ ChartDataPoint(value: 100, label: "一月", color: nil), ChartDataPoint(value: 200, label: "二月", color: nil), ChartDataPoint(value: 150, label: "三月", color: nil) ]) .frame(height: 300) .previewLayout(.fixed(width: 375, height: 300)) } }对于路径计算这种纯逻辑,写单元测试更靠谱:func testMapFunction() { let result = CGFloat(50).map(from: 0, 100, to: 0, 200) XCTAssertEqual(result, 100, accuracy: 0.001) }accuracy参数处理浮点误差,别用XCTAssertEqual直接比浮点数,否则偶尔会挂。6.5 一个我踩过的坑有次改折线图,把smoothness调到 0.8,想做个夸张的曲线效果。结果数据点稀疏时,曲线过冲严重,直接画到了图表外面。排查了半天才发现是控制点偏移量太大,导致贝塞尔曲线超出了数据点的凸包范围。从那以后我每次调平滑系数,都强制在0.1到0.4之间试三档,并且用极值数据(全相等、递增、递减)各跑一遍,确认没有溢出才提交。希望帮到你。本文还有配套的精品资源,点击获取
简介:本资源面向iOS开发初学者与需要自定义图表效果的中级开发者,聚焦Swift中UIBezierPath绘制贝塞尔曲线的核心技巧。通过一个完整的Xcode工程示例,演示如何重写draw(_:)方法、构建三次与二次贝塞尔曲线路径,并将折线数据平滑为美观的数据线,解决系统图表库难以满足个性化视觉需求的问题。压缩包共25个文件,约156KB,包含6个Swift源码文件、3个Objective-C头文件、3个plist配置、2个storyboard与1个xib界面文件,以及工程配置、测试目录和README说明,结构完整可直接运行。目前已有700余人学习下载。读者可从中掌握move(to:)、addCurve与addQuadCurve等关键API的实际用法,理解控制点对曲线形态的影响,并参考示例代码将理论知识落地为可复用的自定义图表视图,适合作为贝塞尔曲线入门的练手项目。1. 用贝塞尔曲线画图表,为什么比直接堆 View 更值得学如果你写过 iOS 图表,大概率经历过这种纠结:用第三方库吧,定制化一改就崩;自己用UIStackView堆柱子吧,圆角、渐变、动画全得靠嵌套,性能还拉胯。这个项目走的是另一条路——直接用UIBezierPath和CAShapeLayer从零画折线图、柱状图和饼图。它适合两类人:一是想搞懂图表底层渲染原理的 Swift 开发者,二是接私活时需要轻量级、无依赖图表方案的人。整个项目不依赖任何第三方库,纯 UIKit + Core Graphics,代码量可控,改起来心里有底。下面我从目录结构开始,把这套东西拆开讲清楚。2. 项目结构与渲染管线:从数据到屏幕像素的完整链路拿到源码先别急着跑,花五分钟把目录翻一遍,后面改代码能省一半时间。这个项目的组织方式很典型,按职责分层,没有花哨的架构,但每一层都卡在关键位置上。2.1 目录分层与文件职责常见的目录结构是这样的:BezierChart/ ├── App/ │ ├── AppDelegate.swift │ └── SceneDelegate.swift ├── Chart/ │ ├── Base/ │ │ ├── ChartView.swift // 图表基类,持有数据源和样式配置 │ │ └── ChartConfig.swift // 颜色、线宽、动画时长等参数 │ ├── Line/ │ │ ├── LineChartView.swift // 折线图主视图 │ │ └── LineChartRenderer.swift // 贝塞尔路径构建与绘制 │ ├── Bar/ │ │ ├── BarChartView.swift │ │ └── BarChartRenderer.swift │ └── Pie/ │ ├── PieChartView.swift │ └── PieChartRenderer.swift ├── Model/ │ └── ChartDataPoint.swift // 数据模型:value、label、color ├── Extension/ │ ├── CGFloat+Extension.swift // 角度转弧度、数值映射 │ └── UIColor+Extension.swift // 渐变色生成 └── Resource/ └── Assets.xcassetsChartView是基类,定义了dataPoints、config和reloadData()三个核心接口。子类只负责实现drawChart(in rect: CGRect)方法,把具体的路径构建逻辑交给对应的 Renderer。这种拆分的好处是:样式配置和数据源在 View 层,路径计算在 Renderer 层,两边互不干扰。你想换一套配色,只动ChartConfig;想改折线平滑算法,只动LineChartRenderer。2.2 渲染管线:CAShapeLayer 才是真正干活的很多人以为图表是draw(_ rect:)画出来的,其实这个项目用的是CAShapeLayer方案。draw(_:)在主线程同步执行,数据量大时直接卡 UI;CAShapeLayer把路径提交给 GPU 合成,还能挂动画。核心链路是这样的:// ChartView.swift 基类中的关键初始化 override init(frame: CGRect) { super.init(frame: frame) // 关闭隐式动画,避免布局时闪烁 layer.masksToBounds = true // 每个图表持有一个 shapeLayer 用于路径绘制 shapeLayer.fillColor = UIColor.clear.cgColor shapeLayer.lineCap = .round shapeLayer.lineJoin = .round layer.addSublayer(shapeLayer) } // 子类重写此方法,由 Renderer 返回构建好的路径 func drawChart(in rect: CGRect) { fatalError("子类必须实现") } override func layoutSubviews() { super.layoutSubviews() shapeLayer.frame = bounds // 布局变化时重新计算路径 drawChart(in: bounds) }这里有几个参数值得注意:lineCap设为.round让折线端点圆润,lineJoin设为.round避免尖角。masksToBounds开启后,超出边界的路径会被裁掉,防止图表溢出。layoutSubviews里重新调用drawChart是必须的,否则旋转屏幕后路径不会更新。2.3 数据映射:把业务数值翻译成坐标点图表的核心就一件事:把[100, 200, 150]这样的数值,映射到屏幕上的CGPoint。这个项目在CGFloat+Extension.swift里封装了映射函数:extension CGFloat { /// 将数值从数据区间映射到绘图区间 /// - Parameters: /// - dataMin: 数据最小值 /// - dataMax: 数据最大值 /// - drawMin: 绘图区最小值(通常是 0) /// - drawMax: 绘图区最大值(通常是视图高度) func map(from dataMin: CGFloat, _ dataMax: CGFloat, to drawMin: CGFloat, _ drawMax: CGFloat) -> CGFloat { // 防止除以零导致 NaN guard dataMax - dataMin != 0 else { return drawMin } let ratio = (self - dataMin) / (dataMax - dataMin) return drawMin + ratio * (drawMax - drawMin) } }调用时,Y 轴需要翻转,因为 UIKit 坐标系原点在左上角:let y = value.map(from: minValue, maxValue, to: chartHeight, 0)minValue和maxValue通常取数据集的极值,再留 10% 的 padding,避免折线贴顶或贴底。这个 padding 系数在ChartConfig里叫yAxisPaddingRatio,默认 0.1,改大改小直接影响图表的视觉呼吸感。3. 折线图核心实现:贝塞尔路径构建与动画落地折线图是三个图表里最复杂的,因为它涉及路径平滑、渐变填充和入场动画。把这部分吃透,柱状图和饼图基本就是降维打击。3.1 构建折线路径:从 move 到 addLineLineChartRenderer的核心方法是buildLinePath(with points: [CGPoint]):func buildLinePath(with points: [CGPoint]) -> UIBezierPath { let path = UIBezierPath() guard points.count > 1 else { return path } // 起点:移动到第一个数据点 path.move(to: points[0]) // 依次连接后续点 for i in 1..<points.count { path.addLine(to: points[i]) } return path }这段代码本身不复杂,但有两个细节容易翻车。第一,points.count必须大于 1,否则move(to:)之后没有线段,路径为空。第二,如果数据点少于 2 个,应该直接返回空路径而不是崩溃。我见过有人在points[0]处直接取下标,数据源为空时闪退,血泪经验。3.2 平滑曲线:用三次贝塞尔替代直线直线连接看起来太硬,很多图表需要平滑曲线。项目里提供了smoothFactor参数,开启后用三次贝塞尔曲线连接:func buildSmoothPath(with points: [CGPoint], smoothness: CGFloat = 0.3) -> 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 curr = points[i] // 计算控制点偏移量 let dx = (curr.x - prev.x) * smoothness let dy = (curr.y - prev.y) * smoothness // 两个控制点分别位于前后点的水平偏移位置 let cp1 = CGPoint(x: prev.x + dx, y: prev.y + dy) let cp2 = CGPoint(x: curr.x - dx, y: curr.y - dy) path.addCurve(to: curr, controlPoint1: cp1, controlPoint2: cp2) } return path }smoothness控制曲线弯曲程度,0 就是直线,0.3 左右比较自然,超过 0.5 会出现明显的过冲。控制点的计算逻辑是:第一个控制点从前一个点沿切线方向偏移,第二个控制点从当前点反向偏移。这样能保证曲线在数据点处平滑过渡,不会出现尖角。3.3 渐变填充:CAGradientLayer 与路径的配合折线下方通常需要渐变填充,做法是把折线路径闭合到 X 轴,然后交给CAGradientLayer做遮罩:func applyGradientFill(to path: UIBezierPath, in rect: CGRect) { // 复制路径并闭合到底部 let fillPath = path.copy() as! UIBezierPath fillPath.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) fillPath.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) fillPath.close() // 创建渐变层 let gradientLayer = CAGradientLayer() gradientLayer.frame = rect gradientLayer.colors = [ config.lineColor.withAlphaComponent(0.4).cgColor, config.lineColor.withAlphaComponent(0.0).cgColor ] gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) // 用路径做遮罩 let maskLayer = CAShapeLayer() maskLayer.path = fillPath.cgPath gradientLayer.mask = maskLayer layer.insertSublayer(gradientLayer, at: 0) }startPoint和endPoint用单位坐标,(0.5, 0) 到 (0.5, 1) 表示从上到下渐变。withAlphaComponent控制透明度,顶部 0.4、底部 0 是最常见的配置。注意insertSublayer(at: 0)把渐变层插到最底层,否则会盖住折线。3.4 入场动画:strokeEnd 从 0 到 1折线图的入场动画用strokeEnd实现,这是CAShapeLayer的专属属性:func animateLineDrawing() { // 先设置路径,再执行动画 shapeLayer.path = linePath.cgPath let animation = CABasicAnimation(keyPath: "strokeEnd") animation.fromValue = 0 animation.toValue = 1 animation.duration = config.animationDuration animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) shapeLayer.add(animation, forKey: "lineDraw") }animationDuration默认 0.8 秒,在ChartConfig里可调。timingFunction用.easeInEaseOut比线性更自然。有个坑:如果shapeLayer.path没提前设置,动画会从空路径开始,看起来像闪一下。所以顺序必须是先设 path,再加动画。4. 柱状图与饼图:复用路径思路解决不同几何问题折线图搞定后,柱状图和饼图的实现思路是相通的——都是构建UIBezierPath,交给CAShapeLayer渲染。区别在于几何计算。4.1 柱状图:圆角矩形与分组布局柱状图的核心是计算每根柱子的 frame,然后用圆角矩形路径绘制:func buildBarPath(at index: Int, value: CGFloat, in rect: CGRect) -> UIBezierPath { let barCount = CGFloat(dataPoints.count) let spacing: CGFloat = config.barSpacing // 柱间距 let totalSpacing = spacing * (barCount + 1) let barWidth = (rect.width - totalSpacing) / barCount let x = spacing + (barWidth + spacing) * CGFloat(index) let barHeight = value.map(from: 0, maxValue, to: 0, rect.height) let barRect = CGRect(x: x, y: rect.height - barHeight, width: barWidth, height: barHeight) // 圆角只加在顶部两个角 let path = UIBezierPath(roundedRect: barRect, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: config.barCornerRadius, height: config.barCornerRadius)) return path }barSpacing默认 8pt,barCornerRadius默认 4pt。byRoundingCorners只让顶部圆角,底部保持直角,这是柱状图的标准视觉。如果数据量大,barWidth会变得很窄,需要设置最小宽度,否则柱子细成一条线。4.2 饼图:扇形路径与角度计算饼图用addArc构建扇形,关键是起始角和结束角的计算:func buildPieSector(startAngle: CGFloat, endAngle: CGFloat, radius: CGFloat, center: CGPoint) -> UIBezierPath { let path = UIBezierPath() // 从圆心移动到起始角边缘 path.move(to: center) // 画弧线到结束角 path.addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true) // 闭合回圆心 path.close() return path }角度用弧度制,CGFloat.pi * 2是整圆。每个扇形的角度 =(value / totalValue) * 2 * pi。注意clockwise参数在 UIKit 坐标系里是反的,传true实际是逆时针方向,这个玄学坑我踩过不止一次。解决办法是统一用true,然后通过调整startAngle和endAngle的顺序来控制方向。4.3 点击选中:hitTest 与路径判断饼图需要响应点击,判断点是否在扇形内。项目里用UIBezierPath.contains(_:)做命中检测:override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { guard let point = touches.first?.location(in: self) else { return } for (index, path) in sectorPaths.enumerated() { if path.contains(point) { // 选中该扇形,放大 1.05 倍 highlightSector(at: index) delegate?.chartView(self, didSelectItemAt: index) break } } }sectorPaths是渲染时缓存的路径数组,避免每次点击重新计算。highlightSector通过transform缩放实现放大效果,配合CABasicAnimation做过渡。注意path.contains对闭合路径有效,如果路径没close(),判断会不准。5. 避坑与常见问题:那些文档不会告诉你的细节这一章记录几个实际开发中高频翻车的点,每个都按「现象 → 原因 → 解决」来说。5.1 图表不显示或只显示一部分现象:reloadData()调了,数据也传了,但屏幕上什么都没有,或者只画了前几个点。原因:shapeLayer.frame没有跟随bounds更新。CAShapeLayer的frame默认是 zero,路径坐标是相对于 layer 自身的,如果 frame 不对,路径就画到屏幕外了。解决:在layoutSubviews里显式设置shapeLayer.frame = bounds,并且确保drawChart在 frame 设置之后调用。如果用了 Auto Layout,layoutSubviews可能被多次调用,加个if oldBounds != bounds的判断避免重复计算。5.2 动画结束后路径消失现象:入场动画播完,折线不见了。原因:CABasicAnimation默认在动画结束后移除,shapeLayer的strokeEnd回到初始值 0。解决:设置animation.isRemovedOnCompletion = false和animation.fillMode = .forwards,让动画停留在最终状态。或者更稳妥的做法是,动画结束后手动设置shapeLayer.strokeEnd = 1。5.3 数据为空时崩溃现象:接口返回空数组,App 直接闪退。原因:points[0]或dataPoints.max()在空数组上取下标或求极值。解决:所有路径构建方法开头加guard !points.isEmpty else { return },maxValue计算时用dataPoints.map { $0.value }.max() ?? 1,给个默认值兜底。5.4 渐变填充颜色不对现象:设置了渐变色,但显示出来是纯色或者颜色错位。原因:CAGradientLayer的frame和mask路径坐标系不一致,或者colors数组里的 CGColor 没有正确转换。解决:确保gradientLayer.frame和maskLayer.path基于同一个坐标系。UIColor.cgColor在暗黑模式下会动态变化,如果需要固定颜色,用UIColor(red:green:blue:alpha:)显式指定。5.5 大量数据时滚动卡顿现象:数据点超过 500 个,图表滑动明显掉帧。原因:每次layoutSubviews都重新构建完整路径,CPU 计算量大。解决:缓存路径,只在数据源变化时重建。可以用dataVersion标记,数据没变就直接复用shapeLayer.path。另外,折线图数据点超过 1000 时,考虑采样降频,每 N 个点取一个,视觉上几乎看不出差别。6. 二次开发:把这套图表改造成你要的样子拿到源码后,最常改的三个地方是:样式配置、数据源适配和新增图表类型。我一般会先跑通 Demo,然后按下面的顺序动手。6.1 样式配置:集中管理而不是散落各处ChartConfig是个 struct,所有可调参数都在里面:struct ChartConfig { var lineColor: UIColor = .systemBlue var lineWidth: CGFloat = 2.0 var animationDuration: TimeInterval = 0.8 var yAxisPaddingRatio: CGFloat = 0.1 var barSpacing: CGFloat = 8.0 var barCornerRadius: CGFloat = 4.0 var showGrid: Bool = true var gridColor: UIColor = .systemGray5 }改配色只动lineColor,改动画时长只动animationDuration。如果项目需要多套主题,可以扩展一个ChartTheme协议,让ChartConfig从主题初始化。这样切换暗黑模式或品牌色时,不用翻遍每个 Renderer。6.2 数据源适配:从 JSON 到 ChartDataPoint实际项目里数据通常来自网络,需要一层转换:struct ChartDataPoint { let value: CGFloat let label: String? let color: UIColor? } // 从接口响应映射 func mapResponse(_ items: [[String: Any]]) -> [ChartDataPoint] { return items.compactMap { dict in guard let value = dict["value"] as? CGFloat else { return nil } return ChartDataPoint(value: value, label: dict["label"] as? String, color: nil) } }compactMap自动过滤掉解析失败的项,避免脏数据导致崩溃。如果接口返回的是字符串数值,记得用CGFloat(string)转换并处理 nil。6.3 新增图表类型:以雷达图为例新增图表遵循同样的模式:继承ChartView,实现drawChart,把路径构建逻辑放到独立的 Renderer。以雷达图为例,核心是计算多边形顶点:func buildRadarPath(values: [CGFloat], center: CGPoint, radius: CGFloat) -> UIBezierPath { let path = UIBezierPath() let angleStep = CGFloat.pi * 2 / CGFloat(values.count) for (index, value) in values.enumerated() { let angle = angleStep * CGFloat(index) - CGFloat.pi / 2 let r = radius * (value / maxValue) let point = CGPoint(x: center.x + cos(angle) * r, y: center.y + sin(angle) * r) index == 0 ? path.move(to: point) : path.addLine(to: point) } path.close() return path }角度减pi/2是为了让第一个顶点朝上。maxValue取所有维度中的最大值,保证图形比例协调。雷达图的网格线是同心多边形,用同样的角度计算,只是半径按比例缩小。6.4 验证方法:用预览和单元测试兜底改完代码后,我习惯用 SwiftUI Preview 快速看效果:struct LineChartView_Previews: PreviewProvider { static var previews: some View { LineChartView(dataPoints: [ ChartDataPoint(value: 100, label: "一月", color: nil), ChartDataPoint(value: 200, label: "二月", color: nil), ChartDataPoint(value: 150, label: "三月", color: nil) ]) .frame(height: 300) .previewLayout(.fixed(width: 375, height: 300)) } }对于路径计算这种纯逻辑,写单元测试更靠谱:func testMapFunction() { let result = CGFloat(50).map(from: 0, 100, to: 0, 200) XCTAssertEqual(result, 100, accuracy: 0.001) }accuracy参数处理浮点误差,别用XCTAssertEqual直接比浮点数,否则偶尔会挂。6.5 一个我踩过的坑有次改折线图,把smoothness调到 0.8,想做个夸张的曲线效果。结果数据点稀疏时,曲线过冲严重,直接画到了图表外面。排查了半天才发现是控制点偏移量太大,导致贝塞尔曲线超出了数据点的凸包范围。从那以后我每次调平滑系数,都强制在0.1到0.4之间试三档,并且用极值数据(全相等、递增、递减)各跑一遍,确认没有溢出才提交。希望帮到你。本文还有配套的精品资源,点击获取