资讯详情

资讯详情

用Core Animation手写iOS折线图:从坐标换算到动画实现

1. 需求拆解与方案选型为什么我用 Core Animation 手写折线图先把场景交代清楚。我在做一个篮球比赛数据直播类的页面需求很简单把主队和客队从第一节到第四节的比分变化画成一条折线用户能看到分数走向点某个节点还能弹出这一节的详细数据。第一反应肯定是找现成的图表库iOS 生态里 Charts、AAChartCore、JHChart 这些都很成熟功能也确实强。但这次我没用原因有三个第一项目里对三方库数量有严格限制为一个折线图引入一个图表库比例上不太划算第二Charts 这类库功能全面但体积也不小好多特性我们这个场景根本用不到第三我的需求其实非常固定两条线、四个节点、一个高亮交互纯自绘完全能搞定还能把动画控制权攥在自己手里。这里多说一句选型思路。很多开发者一看到画图表就想上库这种习惯本身没问题但容易忽视一个关键问题库的抽象层级和你的业务不总对齐。图表库点一般封装了坐标轴、多系列、图例、缩放、拖拽这套抽象是为通用图表设计的你要的只是一条折线 几个点却要为整套机制买单。反过来看Core Animation 里的 CAShapeLayer 配合 UIBezierPath恰好就覆盖了折线图的全部核心诉求折线本质是路径数据点是实心圆动画是 strokeEnd 驱动。到了这一步你就会发现技术选型的核心不是谁功能强而是谁的抽象跟你的问题形状一致。我最初也顺手搜过现成方案看到 echarts 折线图 x 轴刻度调半天才能跟数据对齐python 那边画个折线图也得处理 matplotlib 中文字体和坐标刻度后端同事对着这些折腾了半天。原生 iOS 这里折线图本质上就是把一组数值映射到屏幕坐标再用贝塞尔曲线串起来这个逻辑放在哪个平台都一样但核心差别在于 iOS 有 Core Animation 这套图形渲染管线动画和交互可以做到非常丝滑。这篇文章我就以篮球比赛比分折线图这个实战项目为线索带你走一遍完整的实现过程从坐标换算、路径构建、动画驱动到命中检测全部基于系统自带能力不碰任何第三方图表框架。2. 数据建模与坐标系换算先别急着画线把数学算清楚2.1 比赛数据怎么建模画任何图表之前第一件事是确定数据格式。篮球比赛比分有几个特点数据点固定4 节、每节有主客两个值、时间顺序天然存在。我用一个结构体来表示单个数据点再用一个结构体来承载整场比赛的比分序列struct ScorePoint { let period: Int // 第几节1~4 let label: String // 第1节 第2节 ... let homeScore: CGFloat // 主队累计得分 let guestScore: CGFloat // 客队累计得分 } struct ScoreSeries { let points: [ScorePoint] var homeValues: [CGFloat] { points.map { $0.homeScore } } var guestValues: [CGFloat] { points.map { $0.guestScore } } }这里有个细节值得注意我保存的是累计得分不是单节得分。为什么因为折线图要展示的是趋势累计得分的曲线天然是单调递增的观看者能直观看出哪一节拉开了差距如果你画的是单节得分折线会变得剧烈波动反而失去了比分走势图的意义。在篮球数据分析的场景里累计得分曲线还能顺带展示主队是稳定压制还是末节翻盘这比单节柱状图传递的信息量更大。数据模型定好之后分数的取值范围也确认了一场 NBA 级别的比赛单队全场得分通常在 80~130 之间即使是校园比赛大概也在 30~100 之间。也就是说 y 轴的最小值和最大值不是固定的需要根据当前数据动态计算。我建议不要写死 0~150 这种固定范围——如果某场比赛两队得分都很低折线会缩在图表顶部视觉上非常压抑。正确做法是扫描数据数组找出最小值和最大值再向外扩展一小段作为 padding让折线在整个绘图区域内舒展。2.2 数学坐标到屏幕坐标的换算这个环节是整个图表实现里最容易翻车的地方尤其对刚接触图形绘制的同学来说。UIKit 的坐标系原点在左上角x 轴向右y 轴向下而数学里的坐标系原点通常在左下角y 轴向上。你画折线图的时候得分越高点应该越靠近图表顶部所以换算公式要先想清楚。我习惯把绘图区域定义成一个矩形四周留出边距然后写几个私有方法做坐标换算struct ChartLayout { let chartRect: CGRect let leftPadding: CGFloat let rightPadding: CGFloat let topPadding: CGFloat let bottomPadding: CGFloat let yMax: CGFloat let yMin: CGFloat var innerWidth: CGFloat { chartRect.width - leftPadding - rightPadding } var innerHeight: CGFloat { chartRect.height - topPadding - bottomPadding } } func xPosition(for index: CGFloat, layout: ChartLayout) - CGFloat { let total CGFloat(layout.pointsCount - 1) // 数据点个数减一避免除零 let ratio total 0 ? 0 : index / total return layout.leftPadding ratio * layout.innerWidth } func yPosition(for value: CGFloat, layout: ChartLayout) - CGFloat { let range layout.yMax - layout.yMin let normalized range 0 ? 0 : (value - layout.yMin) / range return layout.topPadding (1 - normalized) * layout.innerHeight }核心就在 y 坐标那段(1 - normalized)把归一化之后的 0~1 值翻转过来。得分等于 yMin 时点落在绘图区底部得分等于 yMax 时点落在顶部。这个翻转忘了写的话折线会上下颠倒我在初学 Core Animation 的 DrawRect 重写时侯就踩过这个坑越改越迷茫后来干脆每次新建一个图表类第一件事就是写一个测试用例验证最小值、最大值、中间值三个点的坐标。把模型和换算逻辑分开还有一个附加好处调试时你可以脱离 UI 层单独跑坐标计算。我习惯为 ChartLayout 写一个纯逻辑的单元测试传入四节比分 22、45、68、91断言 y 坐标是否符合预期。这样后面画线阶段如果图形不对你能快速判断是坐标算错了还是图层画错了避免两个环节的问题纠缠在一起。2.3 留白和刻度的规划很多自绘图表乍看很丑问题往往不是线条不好看而是留白和密度不成比例。在这里我建议两个原则一是绘图区四周一定要有充足边距不要让折线贴到视图边缘二是 x 轴和 y 轴的刻度标签要有固定的生成策略而不是写到哪算哪。x 轴在这个项目里很简单就是 1~4 四节标签分别是第1节到第4节在 x 方向均匀分布即可。y 轴我取其动态范围希望刻度数量控制在 4~6 个之间。最简单的做法是计算范围后除以刻度数量let tickCount 5 let step (yMax - yMin) / CGFloat(tickCount - 1)但这样会出现 12.7、85.3 这种不整齐的刻度值标签上特别难看。更实用的是取整步长法先算出理想步长然后向上取整到我们选定的步长序列1、2、5、10、15、20、25、50 这类再以这个步长从 yMin 向下取整生成刻度。比如理想步长是 13.7向上取整到 15刻度就是 30、45、60、75、90观感一下就舒服了。这一步做不做直接决定了你图表是能跑还是能看我后来做线上图表版本时发现观感差异大部分来自刻度合理性而不是曲线本身。3. Core Animation 核心实现折线路径、绘制与动画3.1 用 UIBezierPath CAShapeLayer 搭出折线骨架坐标换算做完之后画线本身反而成了最简单的一步。思路就是遍历数据点把每个值换算成 CGPoint然后用 UIBezierPath 把这些点连接起来最后赋给 CAShapeLayer 的 path 属性。我不会用曲线连接只用直线。你可能觉得折线图应该平滑一点用 addCurve 加控制点更高级但在比分趋势这个场景里平滑反而会产生误导篮球比赛节与节之间是离散的并不存在连续变化的含义平滑曲线会让用户误以为比分在某段时间内是渐进变化的。直来直往才能准确表达这一节结束时的真实差距。func buildPath(values: [CGFloat], layout: ChartLayout) - CGPath { let bezier UIBezierPath() for (index, value) in values.enumerated() { let point CGPoint( x: layout.xPosition(for: CGFloat(index), layout: layout), y: layout.yPosition(for: value, layout: layout) ) if index 0 { bezier.move(to: point) } else { bezier.addLine(to: point) } } return bezier.cgPath }CAShapeLayer 的配置也有讲究。lineWidth 我建议设成 2 或 3太细视觉上会被背景吞掉太粗又会盖住数据点。lineJoin 和 lineCap 都设成.round折线拐角处就不会出现尖利的直角。strokeColor 每家主队在 UI 上有明确的主题色主队我用橙色客队用蓝色一个是暖色一个是冷色色弱用户也能区分。关键一点fillColor 必须设为UIColor.clear.cgColor因为 UIBezierPath 默认会形成闭合区域如果你忘了关填充折线图就会变成一块梯形色块我第一次写的时候就看到图表中间莫名多了一片棕色查了半天才发现是填充色没置空。3.2 strokeEnd十分钟入门的路径生长动画折线图好看的灵魂在动画。Core Animation 里画线条逐帧出现效果最直接的方式就是操控 layer 的strokeEnd属性从 0 变到 1。它的工作原理可以理解成一个路径可见比例的指示器storkeEnd 等于 0.3 时只有沿着路径方向的前 30% 被渲染出来后面的部分虽然在路径里但不会被画到屏幕上。let animation CABasicAnimation(keyPath: strokeEnd) animation.fromValue 0.0 animation.toValue 1.0 animation.duration 1.2 animation.timingFunction CAMediaTimingFunction(name: .easeInEaseOut) lineLayer.strokeEnd 1.0 // 动画结束后显式设置最终值 lineLayer.add(animation, forKey: lineDraw)这三行代码有一个非常经典的坑动画执行完后layer 显示什么由 model layer 决定而你只设置了strokeEnd的动画没有更新最终模型值动画结束后线条可能跳回 0导致画面一闪而过。所以要养成add(animation)之前先把 model layer 的最终值设好的习惯。CAMediaTimingFunction 为什么选 easeInEaseOut提供一个比较实用的经验初始阶段快一点能让用户立刻感知到图表的生长尾部慢一些让折线到达最后一个点的时候有一个从容的停顿感视觉节奏更舒服。如果你用线性动画会感觉像机械地拉条少了一点书写感。3.3 数据点图层与高亮交互折线只是背景点才是承载信息的核心。每个数据点我生成一个独立的 CALayerframe 是一个 8x8 的矩形cornerRadius 设为 4背景色跟折线同色。点与线的组合能帮用户快速定位每节结束时的具体得分。但这里牵扯出一个容量问题一个点一个 layer还有主队客队两套总共 8 个加两条线这个量级对 Core Animation 来说是轻量中的轻量完全不用担心别把思路带偏。真正要小心的是模拟器上测试动画流畅度完全不可信真机上特别是低端机型才会暴露问题。8 个图层在真机上跑帧率几乎无感但如果你做了复杂的高亮动画比如点击放大圆点再加 shadow那就得考虑shouldRasterize了这个属性在动态变化的场景下反而容易增加开销等会儿我会在后头单独讲。数据点和交互这样处理是最省心的方案给每个数据点创建一个ScoreDotViewUIView 或者 CALayer挂一个 tag 或者 index 属性用 UITapGestureRecognizer 或者hitTest判断点击落在哪个点上。我的做法是给每个点加一个不可见的点击热区层——热区比可见层大一圈比如 28x28这样用户即使点偏了一点也能命中符合人机交互的基本需求。热区太大又会出现两个点之间的点击重叠所以我控制热区半径为 16四个点间隔均匀不至于冲突。4. 实战中的定制细节篮球比赛场景的四个差异化设计4.1 坐标系区分累计分还是分差篮球页面里折线图最常见的两种形态一种是两队各自得分曲线一种是分差曲线主队减客队。这两种形态对 y 轴零线的处理完全不一样值得单独拿出来讲。两队累计分曲线y 轴最小值是 0 或者动态最小值刻度从下往上排语义是得分数值。分差曲线则不同正数表示主队领先负数表示落后y 轴中间必须有一条非常醒目的零线。实现分差曲线时我建议在数据模型层就预先算好差值而不是在图层里去减。原因是图层只关心坐标不关心业务语义而模型层算好了homeScore - guestScore之后生成一组新数组这样做 X 轴不变Y 轴动态范围变宽可能从 -20 到 25但是绘图逻辑完全复用非常干净。分差曲线还有两个额外好处第一用户一眼就能看出比赛是胶着还是一边倒第二它天然为高亮反超点提供了判断依据——如果差值的符号在两个相邻点之间发生翻转说明发生了一次领先权更替这个点在 UI 上可以做高亮标记比如画一个圈这个细节特别受赛事运营同事喜欢。4.2 动态刷新与节间更新的衔接篮球比分图不是静态的比赛中数据会不断更新。这里有一个数据刷新策略前三节结束后用户能看到完整的 1~4 节点吗我明确告诉你不能一次性预填充未来数据否则视觉上会显得失真实——比赛还没打你折线已经画到终场了。我的方案是数据驱动 UI数据源只包含当前已经结束的节次的比分每次数据更新重新生成路径和图层。折线图刷新的时候不要从头播放 strokeEnd 动画否则用户看每节比分更新会以为是重画。节间切换时只做一次淡入淡出新数据来了把老 layer 挪走新 layeropacity从 0 到 1动画时长 0.2 秒然后利用现有图层更新路径。注意这个更新过程是不需要旧的移除再新建的直接给同一个 CAShapeLayer 的 path 赋新值然后手动触发一次跳动式隐式动画不行默认隐式动画会跟手最好在CATransaction里禁用掉然后自己控制变化时机。CATransaction.begin() CATransaction.setDisableActions(true) lineLayer.path newPath.cgPath CATransaction.commit()这样切节次、更新比分都保持画面稳定不闪现、不闪烁。4.3 配合 ScrollView 和页面布局的处理篮球数据页往往不止一个折线图通常上下还会放技术统计表、球员数据表所以折线图被嵌到 UIScrollView 里是常见场景。这里有两个坑一是图层裁切二是滚动性能。刹裁切问题折线线的数据点如果恰好画在绘图区边沿父视图裁切会把它截掉。我的处理是不让折线贴死边缘而是在左右两侧各留 10pt 的呼吸空间这样端点即使在极限得分也不会被裁。如果确实需要精确对齐页面边界就把绘图区略微缩窄而不是改数据范围。第二个坑ScrollView 滚动过程中如果你反复触发 Core Animation 动画比如每次 scrollViewDidScroll 都更新 path那性能会直线下降。正确做法是滚动过程中只移动一个容器 layer不重新生成路径滚动结束或者减速后才根据可见区域更新细节。折线图不像列表需要回收复用一个静态位图就能撑住大部分滚动场景我实测用draw(rect:) 缓存成 UIImageView滚动时比实时提交 layer 路径省太多心。4.4 坐标轴与网格线的视觉降噪最后是让图表有质感的一步坐标轴和网格线。很多人画完折线就收工了结果图表孤零零漂在视图里用户看不到范围、没参照系。我的做法是加一个竖虚线网格配合图例和标题形成完整的图表语义。坐标轴本身我反而简化了不画传统那种四个边框围起来的矩形只画一条 x 轴底线和一条 y 轴左侧线。同时用极浅的灰色画水平网格线对应 y 刻度值。网格线用一个 CAShapeLayer 就可以装完所有的线——把它们全塞进同一个 UIBezierPath而不是一条线一个 layer图层数量保持极低。网格线颜色透明度设成 0.1~0.15 之间密集但不会抢折线的视觉权重。x 轴下方再放四个 UILabel第1节~第4节字号 10 或 11颜色灰色这个层级用 UILayoutGuide 来约束位置就可以。这里给一个我经常用的反直觉经验网格线不要用纯黑、纯灰如果用黑白灰太实会抢内容用主题色的补色、加透明度效果更好。如果你的背景是白色用 rgba(0,0,0,0.1) 就够了。5. 常见问题与性能排查实录5.1 折线不显示或闪一下后消失这几乎是最高频的问题。排查思路其实很简单先看 path 是否真的生成成功了很多情况都是数据源为空或者坐标计算出 NaN。NaN 通常是因为除零——当maxY minY比如 4 节比分都是 0或者单节数据点只有 1 个时分母pointsCount - 1为 0。我习惯在 buildPath 开头加一段保护逻辑如果分母是 0直接 return 一个空 path或者给 yMax 和 yMin 各加 1 的缓冲区。然后是动画那层的问题。strokeEnd动画加完之后我把动画 remove 掉的时候如果 model 层没有设置最终值layer 会恢复成动画前的状态。所以每次加动画之前都检查一次let strokeEnd 1.0设置了没。5.2 折线有锯齿或边缘发虚iOS 上 Core Animation 默认是支持抗锯齿的但如果你遇到锯齿多半是 path 里的坐标不是整点或半像素点或者 layer 的contentsScale出了问题。View 的 contentScale 跟着屏幕 scale 走但如果你手动创建的 CAShapeLayer 是从代码里 add 到某个 layer 上的没有自动继承 view 的 scale那么渲染时可能会走 1x 采样导致发糊。解决办法是手动对每个 ShapeLayer 设置contentsScale UIScreen.main.scale。注意不要整段代码到处设 scale用一次统一管理函数统一指定。5.3 点击无法命中或误伤相邻点命中测试的问题是图表交互里最容易出 bug 的。你在屏幕上看到的是 8pt 大小的圆点但用户手指落上去的误差范围远大于 8pt。我的方案拆解如下为每个点维护一个命中区域矩形比如 24x24在点击事件里遍历所有点计算触摸点与中心点距离若距离小于阈值16pt就判定命中了。如果两个点的距离很近优先返回距离最小的那个点这样就不会出现一个点击同时命中多个点的情况。func dotIndex(at touchPoint: CGPoint, in dots: [ScoreDot]) - Int? { var bestIndex: Int? var bestDistance: CGFloat .greatestFiniteMagnitude for (index, dot) in dots.enumerated() { let dx touchPoint.x - dot.center.x let dy touchPoint.y - dot.center.y let dist sqrt(dx * dx dy * dy) if dist ChartConfig.touchThreshold, dist bestDistance { bestDistance dist bestIndex index } } return bestIndex }这样写还有一个额外收获因为它在 view 坐标系里判断不需要借助 layer hitTest 的底层机制即使点被动画移动位置也能即时更新。5.4 Shadow、光栅化与离屏渲染的性能门道在做高亮点高亮动画的时候我一度想给被选中的点加个扩散阴影结果在 iPhone 8 上测试触摸弹出带了 shadow 的图层时帧数明显波动。后来我把 shadowOpacity 用一项一项排查最终确认是 shadowPath 没设置。Core Animation 的 shadow 如果只设 shadowOpacity 和 shadowOffset会走离屏渲染每次内容变化都要重新计算阴影形状代价非常大。你要是真的需要阴影最直接的办法是先给它一个 shadowPath比如一个 CGPath(ellipseIn: 圆点frame)把它变成路径阴影这会让离屏渲染成本大幅下降。还有shouldRasterize我一直认为它是性能优化神器但在这个场景里要把它反过来用。关于 rasterize 我踩过这么一次折线动画播放到一半时我给 layer 设置了shouldRasterize true以为能减少渲染压力结果每次动画帧变化都要重新生成栅格图反而卡得更明显。后来才明白shouldRasterize只适合静态内容或者只在极少时机变化的内容。折线图一直在动老老实实开着不缓存性能反而更好。5.5 多个折线图共存的资源控制如果页面上不止一个折线图比如首节得分分布、全场得分走势、分差图并排放图层数量会翻倍但量级仍然可控。需要关心的有两点一是动画错峰不要让三个图同时播 strokeEnd 动画用户会视觉疲劳我一般都让第一个图先播第二个延迟 0.15 秒第三个延迟 0.3 秒形成一种有节奏的接力效果二是各自的坐标系隔离不要共享同一个 ChartLayout 实例每个图表实例维护独立的 yMax、yMin否则图表之间的折线高低会被全局最大最小值拖累导致其中某个图表看起来特别扁平。6. 把图表从能用打磨到好用的几点体会最后分享几个我在整个项目里坚持下来的小习惯。首当其冲的是配色。比分折线图的配色决定了很多体验感受直接硬编码UIColor.systemOrange和SystemBlue确实方便但在深色模式下往往不够协调我在整个页面里统一用了自定义主题色配合 iOS 动态颜色的适配深色模式下也能保持足够的对比度。第二个心态是审美克制加渐变背景、加音量效果这种设计师很兴奋的改动我基本不做折线图的核心职责是让用户快速读取数值和趋势任何装饰若减弱了这个职责都值得砍掉。还有一个你可能第一天就会忽略的问题数据刷新和动画播放的冲突。比赛中数据是高频刷新的如果每次刷新都触发一次从 0 到 1 的 strokeEnd 动画用户看到的就是一根线反复重头画。我的策略是首帧数据时候播动画后续增量刷新只更新路径配一个 0.2 秒的隐式过渡刷新和播动画严格分开在两种条件下用代码注释写清楚防止后人踩坑。即时比分直播这种场景折线图真正让人感觉活的地方在于交互反馈的节奏感。比如点击数据点时圆点放大加上一个韧性动画用 spring同时旁边弹出一个比分标签这些细节加起来才让整个图表从能看变成了好用。Core Animation 做这些东西几乎零成本scale 动画用CASpringAnimation弹簧系数设 30阻尼设 0.6效果就已经很自然了不需要额外引入任何动画库。说到底手写这个折线图最让我觉得值的地方不是省了一个三方库的体积而是我完全掌握了自己的渲染逻辑。后面产品经理说折线图上加个平均值线我在布局里加一条虚线就完事又说这节打完标一个半场提示我加两个 CALayer 就能出来。换做用图表库你可能得翻文档找到配置项——还不一定支持。我的建议是除非业务图表真的复杂度很高比如要做大量数据交互、多坐标系联动、实时动态缩放否则从零手绘一个带动画的折线图永远比引库更可控也更值得去做。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →