广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何使用Core Graphics绘制iOS股票分时图:时间轴、价格填充与手势滑动查看详情

股票分时图是交易类App中最常见的图表之一,它展示当日价格随时间的变化曲线。如果完全依赖第三方图表库,包体积和定制成本都会上升,而Core Graphics配合UIKit自定义视图是一种轻量可控的方案。本文以Swift和UIKit为基础,从坐标计算、时间轴刻度、价格区域填充到手势滑动查看详情,完整梳理一个可用的分时图实现过程。

如何使用Core Graphics绘制iOS股票分时图:时间轴、价格填充与手势滑动查看详情

一、价格与时间的坐标映射

绘制分时图最容易忽略的是坐标映射的边界处理。x轴虽然对应时间,但A股交易时段分上午和下午,中间有一个半小时休市。如果直接用Date的timeIntervalSince方法计算时间差,再按固定比例映射到宽度,那么午间会留出一段没有任何数据的空白,整条曲线在中午位置会出现明显断层。更合理的做法是维护一个连续的交易分钟索引,把上午9:30到11:30、下午13:00到15:00当作连续的240分钟序列,然后按索引映射x坐标。

y轴的价格范围不能直接用当日最高价和最低价作为上下限,否则曲线会紧贴图表顶部和底部,既影响美观也影响读数。通常将实际价格区间上下各扩展5%到10%,并保证最小价格跨度,避免涨停或跌停时区间过窄。以下代码定义了一个分时数据点,并演示点坐标的映射逻辑。需要注意的是,这里的ClosedRange是Swift的闭区间类型,代码块中已经对尖括号做了转义。

struct TimeSharePoint {
    let time: Date
    let price: CGFloat
}
func mapPoint(_ point: TimeSharePoint,
              in size: CGSize,
              timeRange: ClosedRange<Date>,
              priceRange: ClosedRange<CGFloat>) -> CGPoint {
    let totalSeconds = timeRange.upperBound.timeIntervalSince(timeRange.lowerBound)
    let elapsed = point.time.timeIntervalSince(timeRange.lowerBound)
    let x = CGFloat(elapsed / totalSeconds) * size.width
    let priceSpan = priceRange.upperBound - priceRange.lowerBound
    let normalizedPrice = (point.price - priceRange.lowerBound) / priceSpan
    let y = size.height * (1 - normalizedPrice)
    return CGPoint(x: x, y: y)
}

坐标映射完成后,还要考虑上下边距和左右边距。如果视图宽度包含坐标轴占位,实际绘图区域会小于bounds。因此建议定义一个绘图区CGRect,后续所有路径和刻度都基于这个区域计算。否则时间标签、价格标签和曲线可能会出现错位。

二、时间轴刻度与网格绘制

时间轴刻度的密度决定了图表的可读性。用户并不需要看到每一分钟的标签,通常按半小时或一小时生成刻度即可。下面这段代码根据数据点数量动态选择步长,在宽度固定的情况下生成约6个时间标签,避免标签文字重叠。注意这里的索引连续映射方式,时间标签的x坐标由索引比例决定,而不是真实时间戳。

func generateTimeLabels(for times: [Date], width: CGFloat) -> [(String, CGFloat)] {
    let step = max(1, times.count / 6)
    var labels: [(String, CGFloat)] = []
    let formatter = DateFormatter()
    formatter.dateFormat = "HH:mm"
    for (index, time) in times.enumerated() where index % step == 0 {
        let x = CGFloat(index) / CGFloat(max(1, times.count - 1)) * width
        labels.append((formatter.string(from: time), x))
    }
    return labels
}

网格线一般使用浅灰色虚线,横向网格数量控制在4到5条。如果网格线直接绘制在价格填充层之上,会显得杂乱;如果完全隐藏在填充层之下,又可能看不清刻度。因此推荐顺序是:先绘制网格线和时间标签,再绘制半透明的价格填充区域,最后绘制价格折线。这样填充色不会完全遮住网格,折线又能保持清晰。

下面是在UIView的draw方法中绘制横向网格和时间标签的示例。为保证半像素线在Retina屏幕上的清晰度,0.5宽度的线可以适当偏移0.25点。循环使用半开区间生成横向网格线。

override func draw(_ rect: CGRect) {
    super.draw(rect)
    guard let context = UIGraphicsGetCurrentContext() else { return }
    context.saveGState()
    context.setStrokeColor(UIColor.lightGray.withAlphaComponent(0.3).cgColor)
    context.setLineWidth(0.5)
    context.setLineDash(phase: 0, lengths: [2, 2])
    let rows: CGFloat = 4
    let rowHeight = rect.height / rows
    for i in 1..<Int(rows) {
        let y = CGFloat(i) * rowHeight
        context.move(to: CGPoint(x: 0, y: y))
        context.addLine(to: CGPoint(x: rect.width, y: y))
    }
    context.strokePath()
    context.restoreGState()
    let paragraph = NSMutableParagraphStyle()
    paragraph.alignment = .center
    let attrs: [NSAttributedString.Key: Any] = [
        .font: UIFont.systemFont(ofSize: 10),
        .foregroundColor: UIColor.gray,
        .paragraphStyle: paragraph
    ]
    for (text, x) in generateTimeLabels(for: times, width: rect.width) {
        let labelRect = CGRect(x: x - 20, y: rect.height - 14, width: 40, height: 12)
        text.draw(in: labelRect, withAttributes: attrs)
    }
}

这个示例中时间标签直接绘制在draw方法里。如果图表需要频繁刷新价格数据,建议把时间标签抽离为UILabel或CATextLayer,减少draw方法中的字符串绘制开销。但对于分时图来说,时间标签通常只在初始化时绘制一次,性能影响有限。

三、价格曲线与渐变填充

价格折线是分时图的核心。创建路径时先移动到第一个映射点,再按顺序连线到最后一个点。分时图通常用直线连接,因为平滑曲线可能会制造出并不存在的价格波动,误导用户对走势的判断。价格填充区域需要把折线末端延伸到底部两个角,再闭合路径,才能为渐变裁剪出正确的区域。

填充的渐变颜色建议从价格线上方较强的半透明色逐渐过渡到接近透明。这样靠近价格线的区域颜色更深,用户视线自然集中到走势上,而底部保持干净。绘制时先使用路径裁剪上下文,再绘制线性渐变。裁剪操作会改变上下文的可绘制区域,所以完成后需要恢复状态。

func drawPriceArea(in context: CGContext, points: [CGPoint], rect: CGRect) {
    guard let first = points.first, let last = points.last else { return }
    let path = CGMutablePath()
    path.move(to: first)
    for point in points.dropFirst() {
        path.addLine(to: point)
    }
    path.addLine(to: CGPoint(x: last.x, y: rect.height))
    path.addLine(to: CGPoint(x: first.x, y: rect.height))
    path.closeSubpath()
    context.saveGState()
    context.addPath(path)
    context.clip()
    let colors = [UIColor.blue.withAlphaComponent(0.25).cgColor,
                  UIColor.blue.withAlphaComponent(0.02).cgColor]
    let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
                              colors: colors as CFArray,
                              locations: [0, 1])!
    context.drawLinearGradient(gradient,
                               start: CGPoint(x: 0, y: rect.minY),
                               end: CGPoint(x: 0, y: rect.maxY),
                               options: [])
    context.restoreGState()
    context.saveGState()
    context.setStrokeColor(UIColor.blue.cgColor)
    context.setLineWidth(1.2)
    context.addPath(path)
    context.strokePath()
    context.restoreGState()
}

这段代码在裁剪后绘制渐变,然后恢复状态再描边折线。如果不是先裁剪再填充,渐变会铺满整个绘图区,视觉上会显得很脏。折线宽度1.2点在普通屏幕和Retina屏幕上都能保持清晰,不建议小于1点,否则在深色背景上容易被忽略。

四、手势滑动与详情浮层

股票分时图最常见的交互是长按或滑动查看某个时间点的价格。UIPanGestureRecognizer可以覆盖这两种场景:手指按下并移动时,手势状态进入changed,我们根据手指在x轴的位置计算对应的数据索引。与点击相比,滑动查看详情更适合连续浏览价格变化,用户手指滑到哪里,浮层就跟随到哪里。

@objc func handlePan(_ sender: UIPanGestureRecognizer) {
    let location = sender.location(in: self)
    guard bounds.width > 0 else { return }
    let index = Int((location.x / bounds.width) * CGFloat(times.count - 1))
    selectedIndex = max(0, min(index, times.count - 1))
    setNeedsDisplay()
}

更新selectedIndex后调用setNeedsDisplay触发重绘。如果每次重绘都重新生成价格路径和填充路径,滑动时会明显掉帧。更合适的做法是把价格曲线和填充区域缓存起来,手势移动时只绘制十字光标和浮层。以下示例展示了十字光标和浮层的绘制逻辑,其中对selectedIndex的边界进行了判断。

func drawCrosshair(in context: CGContext, points: [CGPoint], selectedIndex: Int, rect: CGRect) {
    guard selectedIndex >= 0 && selectedIndex < points.count else { return }
    let point = points[selectedIndex]
    context.saveGState()
    context.setStrokeColor(UIColor.darkGray.cgColor)
    context.setLineWidth(0.8)
    context.setLineDash(phase: 0, lengths: [4, 2])
    context.move(to: CGPoint(x: point.x, y: 0))
    context.addLine(to: CGPoint(x: point.x, y: rect.height))
    context.move(to: CGPoint(x: 0, y: point.y))
    context.addLine(to: CGPoint(x: rect.width, y: point.y))
    context.strokePath()
    context.restoreGState()
    let timeText = timeStrings[selectedIndex]
    let priceText = String(format: "%.2f", points[selectedIndex].y)
    let boxRect = CGRect(x: min(max(point.x - 40, 0), rect.width - 80),
                         y: max(point.y - 30, 0),
                         width: 80,
                         height: 20)
    UIColor.darkGray.setFill()
    UIBezierPath(roundedRect: boxRect, cornerRadius: 3).fill()
    let attrs: [NSAttributedString.Key: Any] = [
        .font: UIFont.boldSystemFont(ofSize: 10),
        .foregroundColor: UIColor.white
    ]
    timeText.draw(at: CGPoint(x: boxRect.minX + 4, y: boxRect.minY + 3), withAttributes: attrs)
}

浮层的横向位置需要进行边界约束,否则选中点靠近屏幕边缘时,浮层会被裁掉。上面通过min和max函数把浮层限制在视图内部。价格文本和价格线颜色通常保持一致,涨用红色、跌用绿色,A股市场习惯红色代表上涨,国际市场的配色可能相反,这一点需要根据业务地区调整。

五、常见问题与性能优化

分时图数据点通常以分钟为粒度,一天约240个点,Core Graphics绘制压力很小。但如果增加历史分时数据或者同时展示多日曲线,路径长度会增加。此时可以把路径缓存为CGPath,数据变化时再重建。手势滑动时只更新光标,避免重复构建路径。

private var cachedPricePath: CGPath?
private var cachedAreaPath: CGPath?
private func rebuildCachedPaths() {
    let mapped = points.map { mapPoint($0, in: bounds.size, timeRange: timeRange, priceRange: priceRange) }
    cachedPricePath = buildLinePath(for: mapped)
    cachedAreaPath = buildAreaPath(for: mapped, height: bounds.height)
}

另一个容易忽略的问题是像素对齐。Core Graphics以点为单位,不同屏幕的scale不同。对于0.5宽度的线,如果坐标落在整数点上,可能被渲染成模糊的一行。绘制网格线前可以先把坐标偏移0.5再取整,或者使用context的像素对齐属性。时间标签的字体大小也不宜小于10点,否则在较小屏幕上难以阅读。

最后,分时图的交互需要和外层滚动视图协调。如果图表放在UIScrollView中,手势滑动可能会与页面滚动冲突。可以通过手势代理设置同时识别失败,或者在图表区域禁用外层滚动。整体封装时,建议把数据源、色值、是否显示浮层等做成可配置属性,方便在不同页面复用。

广告位 · 文章底部(后台-模块管理替换此区域)