绘制K线第四章:滚动优化

148 阅读5分钟

滚动优化

在第三章的基础上,我们发现 Case4 的拖拽功能存在一个问题:只能整根整根地绘制K线,拖拽体验不够平滑。本章介绍如何通过 canvas.translate() 实现像素级滚动来解决这个问题。

一、问题分析

Case4 的问题

Case4 在计算可见区间时,使用了以下逻辑:

val scrollIndex = (scrollOffset / totalCandleWidth).toInt()  // 转换为K线索引偏移
val startIndex = (baseStartIndex - scrollIndex).coerceIn(...)

问题

  • scrollIndex 是通过 toInt() 转换的,只能按整根K线计算
  • 拖拽时,K线只能整根整根地移动,无法显示半根K线
  • 拖拽体验不够平滑,感觉"卡顿"

Case4 的绘制方式

Case4 在绘制每根K线时,需要手动计算X坐标并加上 pixelOffset

val centerX = index * totalCandleWidth + config.candleWidth / 2 + offsetX

这种方式存在以下问题:

  1. 代码复杂:每根K线都需要单独计算X坐标
  2. 可见区间计算不精确:只能按整根K线计算可见范围
  3. 边界处理复杂:需要处理半根K线的显示问题

二、解决方案:使用 canvas.translate()

canvas.translate() 的原理

canvas.translate() 是 Android Canvas 提供的一个坐标系变换方法,用于平移整个坐标系。

工作原理

  1. 保存画布状态canvas.save() 保存当前的变换矩阵
  2. 平移坐标系canvas.translate(dx, dy) 将整个坐标系平移 (dx, dy)
  3. 绘制内容:在平移后的坐标系中绘制,所有坐标都会自动偏移
  4. 恢复画布状态canvas.restore() 恢复之前的变换矩阵

示例

canvas.save()
canvas.translate(-100f, 0f)  // 将坐标系向左平移100像素
canvas.drawRect(0f, 0f, 50f, 50f, paint)  // 实际绘制在屏幕的 (-100, 0) 位置(会被裁剪)
canvas.restore()
canvas.drawRect(0f, 0f, 50f, 50f, paint)  // 实际绘制在屏幕的 (0, 0) 位置

为什么 translate() 能实现半根K线?

关键理解:translate() 平移的是坐标系,而不是画布本身。Canvas 会自动裁剪超出屏幕的部分。

原理

  1. 坐标系平移canvas.translate(translateX, 0f) 平移坐标系
  2. 基于像素计算可见范围
val leftLogicalX = -translateX      // 屏幕左边界在逻辑坐标系中的位置
val rightLogicalX = -translateX + width  // 屏幕右边界在逻辑坐标系中的位置

3. Canvas 自动裁剪:如果K线的一部分在屏幕外,Canvas 会自动裁剪,实现"半根K线"的效果

三、核心实现

1. 计算 translateX(坐标系平移量)

核心思想

  • scrollX = 0 时:最后一根K线的右边界对齐屏幕右边界
  • scrollX = minScrollX 时:第一根K线的左边界对齐屏幕左边界

计算方式

private fun calculateTranslateX(screenWidth: Float, totalCandleWidth: Float): Float {
    val minScrollX = getMinScrollX()
    
    return if (minScrollX < 0) {
        // 线性映射:scrollX 从 0 到 minScrollX,translateX 从 minTranslateX 到 maxTranslateX
        val minTranslateX = screenWidth - klineData.size * totalCandleWidth
        val maxTranslateX = 0f
        (scrollX / minScrollX) * (maxTranslateX - minTranslateX) + minTranslateX
    } else {
        // 数据总宽度小于等于屏幕宽度,不需要滚动,从左往右绘制
        // 第一根K线的左边界对齐屏幕左边界
        0f
    }
}

计算说明

  • minTranslateX = screenWidth - klineData.size * totalCandleWidth:让最后一根K线的右边界对齐屏幕右边界
  • maxTranslateX = 0f:让第一根K线的左边界对齐屏幕左边界
  • 使用线性映射:translateX = (scrollX / minScrollX) * (maxTranslateX - minTranslateX) + minTranslateX
  • 当数据总宽度小于等于屏幕宽度时,translateX = 0f,从左往右绘制

2. 平移坐标系并计算可见范围

// 平移坐标系
canvas.save()
canvas.translate(translateX, 0f)  // 向左平移 translateX 像素(translateX 为负值)

// 计算可见范围
val leftLogicalX = -translateX      // 屏幕左边界在逻辑坐标系中的位置
val rightLogicalX = -translateX + width  // 屏幕右边界在逻辑坐标系中的位置

// 转换为K线索引
val firstVisibleIndex = (leftLogicalX / totalCandleWidth).toInt()
    .coerceAtLeast(0)
    .coerceAtMost(klineData.size - 1)
val lastVisibleIndex = kotlin.math.ceil(rightLogicalX / totalCandleWidth).toInt()
    .coerceAtMost(klineData.size - 1)

说明

  • 右边界使用 ceil 向上取整,确保包含部分可见的K线
  • 这样就能实现半根K线的显示效果

3. 绘制K线

// 获取可见数据并计算价格范围
val visibleData = klineData.subList(firstVisibleIndex, lastVisibleIndex + 1)
val (minPrice, maxPrice) = calculatePriceRange(visibleData)

// 绘制K线(使用固定逻辑坐标)
visibleData.forEachIndexed { relativeIndex, entity ->
    drawCandle(canvas, entity, firstVisibleIndex + relativeIndex, minPrice, maxPrice, height)
}

canvas.restore()

// 绘制网格和标签(在屏幕坐标系中)
drawBackgroundGrid(canvas, width, height)
drawPriceLabels(canvas, minPrice, maxPrice, height, width)
drawTimeLabels(canvas, visibleData, translateX, width, height)

drawCandle 方法

private fun drawCandle(
    canvas: Canvas,
    entity: KLineEntity,
    index: Int,
    minPrice: Float,
    maxPrice: Float,
    height: Float
) {
    // 计算X坐标(基于索引,在逻辑坐标系中)
    val totalCandleWidth = config.getTotalCandleWidth()
    val centerX = index * totalCandleWidth + totalCandleWidth / 2
    // ... 绘制K线
}

说明

  • 每根K线的X坐标基于原始索引计算,是固定值
  • 通过 canvas.translate() 平移坐标系,K线会自动移动到正确位置
  • canvas.restore() 后绘制网格和标签,使用屏幕坐标系

四、Case4 与 Case5 的区别

功能Case4Case5
可见区间计算按整根K线计算按像素计算
支持半根K线
X坐标计算手动计算 + offsetX固定索引,通过 translate 平移
拖拽平滑度一般非常平滑
代码复杂度较高较低

五、优势总结

  1. 代码更简洁:不需要为每根K线单独计算X坐标偏移
  2. 逻辑更清晰:K线的X坐标基于固定索引,通过坐标系变换实现滚动
  3. 支持像素级精度translateX 可以是任意像素值,K线会精确移动
  4. 自动处理边界:半根K线会自动显示在正确位置,不需要额外的边界处理逻辑

六: 效果

系列