导读:本期聚焦于辉辉创作的《如何用Core Graphics实现iOS折线图缩放平移?捏合手势识别、坐标系变换与重绘优化》,敬请观看详情。折线图在iOS数据分析界面中很常见,当数据点激增后,静态图往往难以看清细节。想让折线图像地图那样自由缩放、平移,需要同时处理捏合手势、双指移动以及坐标系映射。直接用UIScrollView虽然省事,但无法对绘制内容做精细化控制,缩放时线条容易模糊或重影。本文基于Core Graphics实现自定义折线图控件,围绕UIPinchGestureRecognizer和UIPanGestureRecognizer,拆解视图坐标与数据坐标的变换关系,并给出重绘性能优化方案:避免频繁创建位图上下文、使用draw(_:)局部刷新、按需降采样绘制。最终得到一个流畅且可定制交互的折线图组件。

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

如何用Core Graphics实现iOS折线图缩放平移?捏合手势识别、坐标系变换与重绘优化

本文将从手势识别开始,逐步讲解如何将屏幕上的触摸位置转换为数据坐标系中的点,并探讨在 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

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。