滚动优化
在第三章的基础上,我们发现 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
这种方式存在以下问题:
- 代码复杂:每根K线都需要单独计算X坐标
- 可见区间计算不精确:只能按整根K线计算可见范围
- 边界处理复杂:需要处理半根K线的显示问题
二、解决方案:使用 canvas.translate()
canvas.translate() 的原理
canvas.translate() 是 Android Canvas 提供的一个坐标系变换方法,用于平移整个坐标系。
工作原理:
- 保存画布状态:
canvas.save()保存当前的变换矩阵 - 平移坐标系:
canvas.translate(dx, dy)将整个坐标系平移 (dx, dy) - 绘制内容:在平移后的坐标系中绘制,所有坐标都会自动偏移
- 恢复画布状态:
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 会自动裁剪超出屏幕的部分。
原理:
- 坐标系平移:
canvas.translate(translateX, 0f)平移坐标系 - 基于像素计算可见范围:
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 的区别
| 功能 | Case4 | Case5 |
|---|---|---|
| 可见区间计算 | 按整根K线计算 | 按像素计算 |
| 支持半根K线 | ❌ | ✅ |
| X坐标计算 | 手动计算 + offsetX | 固定索引,通过 translate 平移 |
| 拖拽平滑度 | 一般 | 非常平滑 |
| 代码复杂度 | 较高 | 较低 |
五、优势总结
- 代码更简洁:不需要为每根K线单独计算X坐标偏移
- 逻辑更清晰:K线的X坐标基于固定索引,通过坐标系变换实现滚动
- 支持像素级精度:
translateX可以是任意像素值,K线会精确移动 - 自动处理边界:半根K线会自动显示在正确位置,不需要额外的边界处理逻辑