移动端的数据可视化需求日益复杂,尤其是金融行情、运动记录、传感器曲线等场景,折线图往往需要展示成百上千个数据点。静态折线图一旦数据量增大,用户无法看清局部波动,交互式缩放和平移就成了刚需。iOS 原生并没有提供专门针对折线图的缩放平移控件,UIScrollView 配合 UIImageView 虽然能实现类似效果,但在缩放过程中需要不断重新生成位图,内存和 CPU 开销较大,而且绘制质量难以控制。利用 Core Graphics 自定义 UIView 并接管手势处理,可以更灵活地管理坐标系并发掘绘制优化空间。

本文将从手势识别开始,逐步讲解如何将屏幕上的触摸位置转换为数据坐标系中的点,并探讨在 draw(_:) 中高效重绘折线图的策略。完整示例基于 Swift 和 UIKit,所有绘制均使用 Core Graphics 完成,不依赖第三方图表库。
同时响应捏合与平移手势
折线图缩放通常使用双指捏合,平移则使用单指拖动。常见的做法是给自定义视图添加 UIPinchGestureRecognizer 和 UIPanGestureRecognizer。但二者同时使用时,系统默认会让平移手势优先识别,导致捏合一开始被误判为平移。为了让两个手势都能正常工作,需要设置手势识别器的委托,在识别过程中允许二者同时进行。
首先在初始化方法中添加手势并设置 delegate。UIPinchGestureRecognizer 的 scale 属性是相对上一次事件变化的系数,需要在手势结束后累加并重置。UIPanGestureRecognizer 的 translation(in:) 方法返回相对起始位置的偏移量,同样需要在手势结束后累加到 contentOffset 中。手势过程中不断更新视图状态并调用 setNeedsDisplay() 触发重绘。
final class LineChartView: UIView, UIGestureRecognizerDelegate {
private var dataPoints: [CGPoint] = []
private var contentScale: CGFloat = 1.0
private var contentOffset: CGPoint = .zero
// 手势过程中的临时累积值
private var lastPinchScale: CGFloat = 1.0
private var lastPanOffset: CGPoint = .zero
override init(frame: CGRect) {
super.init(frame: frame)
setupGestures()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupGestures()
}
private func setupGestures() {
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))
pinch.delegate = self
addGestureRecognizer(pinch)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.delegate = self
addGestureRecognizer(pan)
}
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
switch gesture.state {
case .began:
lastPinchScale = gesture.scale
case .changed:
let newScale = lastPinchScale * gesture.scale
contentScale = max(0.5, min(newScale, 10.0))
setNeedsDisplay()
case .ended, .cancelled, .failed:
lastPinchScale = 1.0
default:
break
}
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: self)
switch gesture.state {
case .began:
lastPanOffset = contentOffset
case .changed:
contentOffset = CGPoint(x: lastPanOffset.x + translation.x,
y: lastPanOffset.y + translation.y)
setNeedsDisplay()
case .ended, .cancelled, .failed:
lastPanOffset = .zero
default:
break
}
}
// 允许捏合和平移同时识别
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPinchGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer {
return true
}
if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPinchGestureRecognizer {
return true
}
return false
}
}
上面的代码中,contentScale 表示当前缩放倍数,contentOffset 表示数据坐标系相对视图左上角的偏移。手势过程中直接修改这两个属性,然后触发 draw(_:) 重新绘制。为了限制缩放范围,contentScale 被约束在 0.5 到 10.0 之间,避免用户无意义地放大或缩小。
需要注意,UIPanGestureRecognizer 默认最少需要 1 个手指,而捏合手势需要双指。两个手势同时识别时,单指移动会立即触发平移,双指捏合则会同时触发捏合与平移。实际使用时,可以根据需求调整代理方法,例如只在捏合手势开始后禁用平移,但这样会让交互不够顺滑。通常允许同时识别更符合地图类应用的交互习惯。
屏幕坐标与数据坐标的变换
折线图的数据点通常存储为逻辑数据坐标,例如横轴为时间或索引,纵轴为数值。而 Core Graphics 的绘图区域是视图的 bounds,两者之间需要建立映射关系。缩放和平移的本质就是改变这个映射关系中的参数。
假设原始数据范围是已知的,比如 x 从 0 到 dataPoints.count-1,y 从 0 到 maxValue。在没有缩放平移时,可以将数据坐标线性映射到视图坐标。引入 contentScale 和 contentOffset 后,数据点 (dataX, dataY) 在视图中的位置计算公式如下:
viewX = (dataX + contentOffset.x) * pointWidth * contentScale
viewY = viewHeight - (dataY + contentOffset.y) * pointHeight * contentScale
其中 pointWidth 和 pointHeight 是每个数据单位在无缩放时对应的像素长度。contentOffset 是在数据坐标系中的偏移量,这样平移操作就不会改变缩放中心。
反向转换同样重要。当用户点击视图时,需要知道对应的数据坐标。例如调试或添加数据标记时,可以写一个方法将触摸点转换为数据点。代码如下:
extension LineChartView {
// 将数据坐标转换为视图坐标
func viewPoint(forDataPoint dataPoint: CGPoint) -> CGPoint {
let x = (dataPoint.x + contentOffset.x) * pointWidth * contentScale
let y = bounds.height - (dataPoint.y + contentOffset.y) * pointHeight * contentScale
return CGPoint(x: x, y: y)
}
// 将视图坐标转换为数据坐标
func dataPoint(forViewPoint viewPoint: CGPoint) -> CGPoint {
let x = viewPoint.x / (pointWidth * contentScale) - contentOffset.x
let y = (bounds.height - viewPoint.y) / (pointHeight * contentScale) - contentOffset.y
return CGPoint(x: x, y: y)
}
}
上述代码中的 pointWidth 和 pointHeight 需要在布局或数据更新时计算。通常将数据范围映射到视图的可用区域,并保留一定的内边距。pointWidth = (bounds.width - leftPadding - rightPadding) / CGFloat(dataPoints.count - 1),pointHeight = (bounds.height - topPadding - bottomPadding) / (maxValue - minValue)。如果数据点只有一个,需要特殊处理避免除以零。
使用数据坐标作为 contentOffset 的维度,可以让平移距离与缩放级别无关。用户想看到数据窗口外的内容时,只需改变 contentOffset,重绘时映射公式自动生效。这也方便实现边界限制:根据当前缩放级别计算最小和最大 contentOffset,防止用户把曲线完全拖出可视区域。
Core Graphics 绘制与性能优化
重写 draw(_:) 方法是折线图绘制的核心。在该方法中获取当前绘图上下文,遍历数据点并转化为视图坐标,然后用 UIBezierPath 或直接使用 CGContext 绘制路径。为了提升性能,可以先将所有转换后的点存储起来,避免在循环中重复计算。
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext(), dataPoints.count > 1 else { return }
context.setStrokeColor(UIColor.systemBlue.cgColor)
context.setLineWidth(2.0)
context.setLineJoin(.round)
context.setLineCap(.round)
let path = CGMutablePath()
for (index, dataPoint) in dataPoints.enumerated() {
let viewPoint = viewPoint(forDataPoint: dataPoint)
if index == 0 {
path.move(to: viewPoint)
} else {
path.addLine(to: viewPoint)
}
}
context.addPath(path)
context.strokePath()
}
上面的代码在每次手势变化时都会触发 draw(_:),路径包含所有数据点。如果数据点数量多达数万个,每次全量绘制会造成明显的性能瓶颈。优化手段可以从以下几方面入手:首先,使用降采样或数据聚合,只绘制当前缩放级别下可见范围内的点。当缩放级别较小时,可视区域内包含的数据点很多,但屏幕像素有限,可以每隔 N 个点取一个进行绘制,N 根据缩放比例动态调整。当用户放大后,可视范围缩小,再逐步恢复原始数据点。
其次,尽量减少视图的无效重绘区域。如果只是平移操作,理论上可以复用上一次绘制的位图内容,仅在新暴露的边缘区域进行补充绘制。但实现较为复杂,且容易产生残留线条。一个折中方案是维护一个离屏位图缓存,手势结束后再执行一次全量绘制,手势过程中使用较轻量的近似绘制(例如只绘制折线段的端点和中间采样点)。这样既能保证交互流畅,又能在松手后呈现完整精细的图形。
// 根据缩放比例动态计算采样间隔
private func samplingInterval() -> Int {
let visibleDataCount = Int(bounds.width / (pointWidth * contentScale))
guard visibleDataCount > 0 else { return dataPoints.count }
let interval = max(1, dataPoints.count / visibleDataCount)
return interval
}
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext(), dataPoints.count > 1 else { return }
context.setStrokeColor(UIColor.systemBlue.cgColor)
context.setLineWidth(2.0)
context.setLineJoin(.round)
context.setLineCap(.round)
let interval = samplingInterval()
let path = CGMutablePath()
var isFirstPoint = true
// 使用 stride 跳过不需要绘制的点
var index = 0
while index < dataPoints.count {
let point = viewPoint(forDataPoint: dataPoints[index])
if isFirstPoint {
path.move(to: point)
isFirstPoint = false
} else {
path.addLine(to: point)
}
index += interval
}
context.addPath(path)
context.strokePath()
}
这段降采样代码中,samplingInterval() 方法根据当前可视宽度能容纳的数据点个数动态计算采样间隔。当用户缩小视图时,interval 增大,绘制的线段数量大幅减少;放大时 interval 逐渐恢复为 1,保证细节精确。这样在缩放和平移过程中,CPU 负担不会随着数据总量线性增长。
除了降采样,还应该避免在 draw(_:) 中创建大量临时对象。CGMutablePath 和 CGPoint 的创建开销很小,但如果数据点非常多,尽量复用数组存储转换后的点。另外,如果折线图有填充、渐变或阴影效果,这些操作会显著增加渲染时间,在缩放过程中可以暂时关闭或简化,待手势结束后再恢复完整效果。
完整控件封装与交互细节
为了实现一个可复用的折线图缩放平移控件,还需要对外暴露数据源和样式配置接口。例如提供 setDataPoints(_ points: [CGPoint]) 方法更新数据,提供 lineColor、lineWidth、minScale、maxScale 等属性。同时要处理视图尺寸变化时的重绘,在 layoutSubviews 中重新计算 pointWidth 和 pointHeight 并调用 setNeedsDisplay()。
extension LineChartView {
func setDataPoints(_ points: [CGPoint]) {
dataPoints = points
recalculatePointMetrics()
setNeedsDisplay()
}
private func recalculatePointMetrics() {
guard dataPoints.count > 1 else { return }
let leftPadding: CGFloat = 20
let rightPadding: CGFloat = 20
let topPadding: CGFloat = 20
let bottomPadding: CGFloat = 20
let maxX = dataPoints.map { $0.x }.max() ?? 1
let minX = dataPoints.map { $0.x }.min() ?? 0
let maxY = dataPoints.map { $0.y }.max() ?? 1
let minY = dataPoints.map { $0.y }.min() ?? 0
let availableWidth = bounds.width - leftPadding - rightPadding
let availableHeight = bounds.height - topPadding - bottomPadding
pointWidth = availableWidth / max((maxX - minX), 1)
pointHeight = availableHeight / max((maxY - minY), 1)
}
override func layoutSubviews() {
super.layoutSubviews()
recalculatePointMetrics()
setNeedsDisplay()
}
}
另一个需要注意的细节是手指离开屏幕后的惯性效果。UIKit 的手势识别器没有提供惯性回调,如果需要类似地图的惯性滑动,可以通过手势结束时的速度手动实现。记录 pan 手势在最后一刻的 velocity(in:),然后使用 CADisplayLink 或 UIView.animate 递减 contentOffset,直到速度趋近于零。这个功能可以显著提升用户体验,但实现时要注意线程安全和动画与手势的冲突。
对于双指缩放时围绕缩放中心进行缩放的需求,上面的简单实现是以视图原点为缩放锚点,用户捏合时折线图会围绕左下角或中心缩放。更自然的做法是以手势中心点为锚点进行缩放,这需要在缩放公式中加入缩放中心的修正。常见的公式是:contentOffset = gestureCenterDataPoint - (gestureCenterViewPoint / scale)。由于篇幅限制,这里不展开,读者可以在此基础上扩展。
最终,经过手势处理和坐标系变换的封装,这个基于 Core Graphics 的折线图组件可以流畅地支持数千个数据点的缩放与平移,并且代码结构清晰,易于根据业务需求定制绘制样式和交互行为。
Core Graphics折线图缩放平移捏合手势识别修改时间:2026-08-25 10:31:21