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

iOS端如何用Core Graphics实现股票K线图十字线交互与价格时间吸附?

股票K线图的十字线交互看似只是两条交叉的虚线,实际上牵涉到坐标映射、索引反算、文本定位和重绘性能等一系列细节。手指在图表上滑动时,垂直虚线需要准确落在某根K线的中心,水平虚线则跟随指尖高度,同时在右侧和底部显示对应的价格与时间信息。要实现这一效果,不能直接使用触摸点坐标,而要把图表区域、K线宽度和价格区间建立清晰的数学关系。

iOS端如何用Core Graphics实现股票K线图十字线交互与价格时间吸附?

一、十字线交互的坐标基础与绘图区域划分

在自定义UIView中绘制K线图,首先要明确绘图区域与坐标轴区域是分离的。左侧通常预留价格轴,底部预留时间轴,顶部和右侧也要留出一定的内边距,避免蜡烛影线被裁切。假设控件总宽度为bounds.width,左侧预留60pt显示价格,右侧预留60pt显示最新价格,顶部和底部分别预留20pt和30pt,那么实际绘制K线的区域就是bounds去掉这四个内边距后得到的chartRect。所有十字线相关的坐标计算都必须基于这个chartRect,而不是直接使用手指在屏幕上的位置。

横坐标映射相对直接。每根K线占据固定宽度,将chartRect的宽度除以可见K线数量,就得到candleWidth。第index根K线的中心横坐标可以表示为chartRect.minX加上index加0.5再乘以candleWidth。这里的0.5偏移量很关键,它能保证蜡烛实体落在每个区间的中央,而不是贴着区间左边界。纵坐标则要根据可见数据中的最高价和最低价来换算。为了避免最高价或最低价恰好贴在图表上下边缘,可以在最高最低价之外再扩展大约5%的缓冲区间。

下面先定义一个Candle模型和KLineChartView的基础结构。代码中chartRect和candleWidth都通过计算属性获得,这样当控件尺寸变化时会自动重新计算,避免在draw方法里硬编码尺寸。

import UIKit
struct Candle {
    var open: CGFloat
    var high: CGFloat
    var low: CGFloat
    var close: CGFloat
    var date: String
}
class KLineChartView: UIView {
    var candles: [Candle] = []
    var selectedIndex: Int?
    private let topInset: CGFloat = 20
    private let bottomInset: CGFloat = 30
    private let leftInset: CGFloat = 60
    private let rightInset: CGFloat = 60
    private var candleWidth: CGFloat {
        guard !candles.isEmpty else { return 0 }
        let chartWidth = bounds.width - leftInset - rightInset
        return chartWidth / CGFloat(candles.count)
    }
    private var chartRect: CGRect {
        CGRect(x: leftInset,
               y: topInset,
               width: bounds.width - leftInset - rightInset,
               height: bounds.height - topInset - bottomInset)
    }
    override func draw(_ rect: CGRect) {
        guard !candles.isEmpty else { return }
        drawCandles()
        if let index = selectedIndex {
            drawCrosshair(at: index, touchY: chartRect.midY)
        }
    }
}

二、十字线的绘制与虚线样式

十字线由一条垂直虚线和一条水平虚线组成。垂直虚线对应吸附后的K线中心横坐标,水平虚线对应手指当前的竖直位置。两者通常都采用虚线样式,因为实线在密集的K线图上会显得过于突兀,虚线可以在保持定位能力的同时减少视觉遮挡。绘制虚线时使用UIBezierPath的setLineDash方法,传入一个长度数组,例如[4, 3]表示4pt线段后接3pt间隔。

垂直线的横坐标计算非常明确,直接使用centerX等于chartRect.minX加上index加0.5再乘以candleWidth。而水平线的纵坐标则来自触摸点。如果触摸点超出了chartRect的上下边界,需要将其钳制到chartRect.minY和chartRect.maxY之间,否则水平虚线会消失在图表的坐标轴区域。线宽建议设置为1.0除以UIScreen.main.scale,这样在Retina屏幕上可以保持1像素的清晰度,不会出现模糊的发虚效果。

下面的代码展示了如何在一个drawCrosshair方法中同时绘制垂直虚线和水平虚线。chartRect已经预先计算好,touchY从手势回调中传入。

private func drawCrosshair(at index: Int, touchY: CGFloat) {
    guard index >= 0 && index < candles.count else { return }
    let centerX = chartRect.minX + (CGFloat(index) + 0.5) * candleWidth
    let crossColor = UIColor(white: 0.25, alpha: 1.0)
    let path = UIBezierPath()
    path.move(to: CGPoint(x: centerX, y: chartRect.minY))
    path.addLine(to: CGPoint(x: centerX, y: chartRect.maxY))
    path.move(to: CGPoint(x: chartRect.minX, y: touchY))
    path.addLine(to: CGPoint(x: chartRect.maxX, y: touchY))
    crossColor.setStroke()
    path.lineWidth = 1.0 / UIScreen.main.scale
    path.setLineDash([4, 3], count: 2, phase: 0)
    path.stroke()
}

有些行情软件还会在两条虚线的交叉位置绘制一个小圆点或高亮背景,帮助用户快速定位当前选中的K线。这个交叉点就是centerX和touchY组成的坐标。绘制小圆点时半径不宜过大,一般2到4pt即可,颜色可以与虚线保持一致,也可以使用更醒目的强调色。需要注意的是,小圆点和虚线一样,都属于动态覆盖层,更新频率很高,因此要控制绘制开销。

三、价格与时间标签的定位和边界钳制

只有两条虚线还不足以完成交互,用户需要知道十字线当前对应的价格和时间。价格标签通常放置在图表右侧,跟随水平虚线上下移动;时间标签则放在图表底部,以垂直虚线为中心左右居中。文本绘制可以使用NSAttributedString,先测量文本尺寸,再确定标签背景的位置。标签背景一般使用深色半透明填充,以保证在不同K线颜色背景下都清晰可读。

价格标签的横向位置可以固定为chartRect.maxX加上4pt的间距,纵向位置则用touchY减去文本高度的一半,使标签与水平虚线保持在同一中心线上。但如果价格标签距离图表顶部或底部太近,标签背景会超出chartRect范围,与坐标轴重叠。因此需要对touchY进行边界限制,或者在计算标签y坐标后重新钳制。一个简单的做法是在绘制前计算标签高度,如果标签上边缘小于chartRect.minY,就把标签上边缘对齐chartRect.minY;如果标签下边缘大于chartRect.maxY,就把标签下边缘对齐chartRect.maxY。

时间标签更容易出现左右溢出问题。当十字线位于第一根K线或最后一根K线时,时间标签宽度的一半会超出chartRect边界,甚至被屏幕边缘裁剪。解决办法是先测量文字宽度,然后根据centerX计算理想标签左边缘。如果左边缘小于chartRect.minX,就将其对齐到chartRect.minX;如果右边缘大于chartRect.maxX,就将其对齐到chartRect.maxX。这样时间标签始终完整显示在图表范围内。

下面这段代码展示了价格标签和时间标签的绘制过程。实际开发中可以根据数据格式将date字段转换为合适的显示格式,这里直接使用原始字符串。

private func drawPriceLabel(touchY: CGFloat, chartRect: CGRect) {
    let price = priceValue(fromY: touchY, chartRect: chartRect)
    let text = String(format: "%.2f", price)
    let attributes: [NSAttributedString.Key: Any] = [
        .font: UIFont.systemFont(ofSize: 10),
        .foregroundColor: UIColor.white
    ]
    let textSize = text.size(withAttributes: attributes)
    let labelRect = CGRect(x: chartRect.maxX + 4,
                           y: touchY - textSize.height / 2,
                           width: textSize.width + 8,
                           height: textSize.height + 4)
    UIColor(white: 0.2, alpha: 0.9).setFill()
    UIBezierPath(roundedRect: labelRect, cornerRadius: 3).fill()
    text.draw(in: CGRect(x: labelRect.minX + 4,
                         y: labelRect.minY + 2,
                         width: textSize.width,
                         height: textSize.height),
              withAttributes: attributes)
}
private func drawTimeLabel(index: Int, chartRect: CGRect) {
    guard index >= 0 && index < candles.count else { return }
    let text = candles[index].date
    let attributes: [NSAttributedString.Key: Any] = [
        .font: UIFont.systemFont(ofSize: 10),
        .foregroundColor: UIColor.white
    ]
    let textSize = text.size(withAttributes: attributes)
    let centerX = chartRect.minX + (CGFloat(index) + 0.5) * candleWidth
    var labelX = centerX - (textSize.width + 8) / 2
    let minX = chartRect.minX
    let maxX = chartRect.maxX - textSize.width - 8
    if labelX < minX {
        labelX = minX
    } else if labelX > maxX {
        labelX = maxX
    }
    let labelRect = CGRect(x: labelX,
                           y: chartRect.maxY + 4,
                           width: textSize.width + 8,
                           height: textSize.height + 4)
    UIColor(white: 0.2, alpha: 0.9).setFill()
    UIBezierPath(roundedRect: labelRect, cornerRadius: 3).fill()
    text.draw(in: CGRect(x: labelRect.minX + 4,
                         y: labelRect.minY + 2,
                         width: textSize.width,
                         height: textSize.height),
              withAttributes: attributes)
}

四、最近K线吸附与滑动性能优化

吸附效果是十字线交互能否准确读取数据的关键。手指在屏幕上滑动时,触摸点的横坐标几乎不可能正好落在某根K线的中心,如果直接使用这个横坐标绘制垂直虚线,虚线就会停在两根K线之间,用户无法判断价格到底属于哪一根。正确的做法是根据触摸点反算最近的K线索引,再将垂直虚线吸附到该K线中心。反算公式为:先计算rawIndex等于point.x减去chartRect.minX再除以candleWidth,然后减去0.5,最后取四舍五入值。这个0.5正好对应中心点偏移,四舍五入后得到的就是最近的整数索引。

得到索引后,还需要进行边界钳制。使用min和max组合可以快速将索引限制在0到candles.count减1之间。这样即使用户手指滑动到图表左边缘或右边缘之外,也不会因为索引越界导致崩溃。吸附完成后,垂直虚线会出现一个明显的跳动,这是正常现象,用户能直观感受到K线被选中。触摸结束时,可以选择清空selectedIndex让十字线消失,也可以保持最后选中的K线,具体取决于产品交互习惯。

性能方面,如果每次手指移动都调用setNeedsDisplay,会触发draw方法重新绘制所有K线和十字线。当K线数量较多时,频繁全量重绘会造成明显的耗电和掉帧。更好的做法是将静态的K线画在一个底层视图或图层中,将十字线和标签放在独立的CAShapeLayer或透明覆盖视图上。滑动时只更新十字线层的path,这样K线部分完全不需要重绘。

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first else { return }
    let point = touch.location(in: self)
    guard chartRect.contains(point), !candles.isEmpty else { return }
    let rawIndex = (point.x - chartRect.minX) / candleWidth - 0.5
    let index = Int(rawIndex.rounded())
    let clampedIndex = min(max(index, 0), candles.count - 1)
    selectedIndex = clampedIndex
    crosshairLayer.update(index: clampedIndex,
                          touchY: point.y,
                          chartRect: chartRect,
                          candleWidth: candleWidth)
}

CrosshairLayer继承自CAShapeLayer,只负责维护虚线路径。更新时重新构建UIBezierPath并赋值给path属性即可。由于CAShapeLayer的绘制不依赖draw方法,因此不会触发底层K线视图的重绘。还需要注意设置contentsScale为UIScreen.main.scale,否则在Retina屏幕上路径可能会显得模糊。当屏幕尺寸变化或设备旋转时,要重新计算chartRect和candleWidth,并更新一次十字线路径。

class CrosshairLayer: CAShapeLayer {
    override init() {
        super.init()
        contentsScale = UIScreen.main.scale
        lineWidth = 1.0 / UIScreen.main.scale
        lineDashPattern = [4, 3]
        strokeColor = UIColor(white: 0.25, alpha: 1.0).cgColor
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        contentsScale = UIScreen.main.scale
    }
    func update(index: Int, touchY: CGFloat, chartRect: CGRect, candleWidth: CGFloat) {
        let centerX = chartRect.minX + (CGFloat(index) + 0.5) * candleWidth
        let path = UIBezierPath()
        path.move(to: CGPoint(x: centerX, y: chartRect.minY))
        path.addLine(to: CGPoint(x: centerX, y: chartRect.maxY))
        path.move(to: CGPoint(x: chartRect.minX, y: touchY))
        path.addLine(to: CGPoint(x: chartRect.maxX, y: touchY))
        self.path = path.cgPath
    }
}

通过坐标体系梳理、虚线样式控制、标签边界钳制以及最近K线吸附,再利用CAShapeLayer隔离动态与静态内容,就能在iOS端用Core Graphics实现流畅的股票K线图十字线交互。这套思路同样适用于分时图、MACD副图等需要定位数据点的场景,关键是保持图表区域与数据索引之间的映射清晰,避免在触摸回调中堆积过多重复计算。

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