绘制K线第三章:拖拽功能实现
在第二章的基础上,我们添加拖拽功能,让用户可以左右滑动查看不同时间段的K线数据。
一、设计思路
新增基类处理拖拽逻辑
为了不影响原先K线的绘制逻辑,我们创建一个新的基类 ScrollableKLineView 来专门处理拖拽功能。
设计原则:
- 基类只负责拖拽和滚动逻辑
- 子类继承基类,实现具体的绘制逻辑
- 子类通过实现
getMinScrollX()和getMaxScrollX()来定义滚动边界
优势:
- 拖拽逻辑与绘制逻辑分离
二、模型说明
类图

ScrollableKLineView 类结构
核心组件:
scrollX: Float:当前滚动偏移量(X方向)
-
scrollX = 0:显示最新数据(最右边)scrollX < 0:向左滚动,显示更早的数据
GestureDetector:检测拖动手势OverScroller:处理快速滑动(惯性滚动)isTouching: Boolean:是否正在触摸
关键方法:
getMinScrollX(): Float:获取最小滚动位置(子类实现)getMaxScrollX(): Float:获取最大滚动位置(子类实现)scrollTo(newScrollX: Float):滚动到指定位置
滚动边界:
scrollX的范围:[getMinScrollX(), getMaxScrollX()]minScrollX:最左边的边界(负值)maxScrollX:最右边的边界(0,显示最新数据)
三、核心逻辑
1. 拖动处理(onScroll)
触发时机:用户手指在屏幕上拖动
处理流程:
scss
用户拖动手指
↓
onScroll() 被调用
↓
scrollTo(scrollX + distanceX) - 计算新的滚动位置并更新
↓
postInvalidateOnAnimation() - 触发重绘
代码实现:
kotlin
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
// distanceX > 0:手指向右拖动,数据向右移动,scrollX 增大
// distanceX < 0:手指向左拖动,数据向左移动,scrollX 减小
scrollTo(scrollX + distanceX)
return true
}
private fun scrollTo(newScrollX: Float) {
val minScrollX = getMinScrollX()
val maxScrollX = getMaxScrollX()
val oldScrollX = scrollX
scrollX = newScrollX
// 边界检查和限制
if (scrollX < minScrollX) {
scrollX = minScrollX
scroller.forceFinished(true)
} else if (scrollX > maxScrollX) {
scrollX = maxScrollX
scroller.forceFinished(true)
}
if (scrollX != oldScrollX) {
postInvalidateOnAnimation()
}
}
关键点:
distanceX是手指移动的距离,向右为正,向左为负- 滚动方向跟随手指 :手指向右拖动,数据向右移动,
scrollX增大 onScroll中直接调用scrollTo(scrollX + distanceX),简化代码逻辑- 边界检查在
scrollTo中统一处理,避免回弹
2. 快速滑动处理(onFling)
触发时机:用户快速滑动后抬起手指
处理流程:
scss
用户快速滑动后抬起手指
↓
onFling() 被调用
↓
检查边界
↓
scroller.fling() - 启动惯性滚动
↓
computeScroll() - 持续更新滚动位置
↓
scrollTo() - 更新位置并检查边界
↓
postInvalidateOnAnimation() - 继续动画
代码实现:
kotlin
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
if (!isTouching) {
val minScrollX = getMinScrollX()
val maxScrollX = getMaxScrollX()
// velocityX > 0:向右快速滑动,数据向右移动,scrollX 增大
// velocityX < 0:向左快速滑动,数据向左移动,scrollX 减小
// 边界检查:如果已经到达边界且继续向边界方向滑动,则不触发快速滑动
if ((scrollX <= minScrollX && velocityX < 0) || (scrollX >= maxScrollX && velocityX > 0)) {
scroller.forceFinished(true)
return false
}
// velocityX 需要取反,以匹配我们的滚动方向
scroller.fling(
scrollX.toInt(), 0,
(-velocityX).toInt(), 0,
minScrollX.toInt(), maxScrollX.toInt(),
0, 0
)
invalidate()
}
return true
}
关键点:
velocityX是滑动速度,向右为正,向左为负- velocityX 需要取反:因为我们的滚动方向与标准实现相反
- 使用
OverScroller实现平滑的惯性滚动动画
3. 滚动更新(computeScroll)
触发时机:系统自动调用,用于更新滚动动画
处理流程:
scss
系统自动调用 computeScroll()
↓
scroller.computeScrollOffset() - 计算下一帧位置
↓
scrollTo(scroller.currX) - 更新位置
↓
postInvalidateOnAnimation() - 继续下一帧
↓
重复直到 scroller 完成
代码实现:
kotlin
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
if (!isTouching) {
// 直接使用 scroller.getCurrX()
// scrollTo 内部会进行边界检查和限制
scrollTo(scroller.currX.toFloat())
// 继续动画,直到 scroller 完成
postInvalidateOnAnimation()
} else {
// 用户开始触摸,停止滚动
scroller.forceFinished(true)
}
}
}
关键点:
scrollTo方法内部统一处理边界检查和限制- 到达边界时自动停止滚动,避免回弹
- 使用
postInvalidateOnAnimation()继续动画
4. 外部如何获取滚动边界
子类实现边界计算:
在 KLineViewCase4 中,子类需要实现 getMinScrollX() 和 getMaxScrollX() 来定义滚动边界:
kotlin
override fun getMinScrollX(): Float {
if (klineData.isEmpty() || width == 0) return 0f
val width = width.toFloat()
val totalCandleWidth = config.getTotalCandleWidth()
val totalWidth = klineData.size * totalCandleWidth
// 如果数据总宽度小于等于屏幕宽度,不需要滚动
if (totalWidth <= width) {
return 0f
}
// 最小滚动位置:确保最左边的数据刚好显示在屏幕左边界
return (width - totalWidth)
}
override fun getMaxScrollX(): Float {
// 最大滚动位置:0(显示最新数据,即最右边)
return 0f
}
边界计算逻辑:
maxScrollX = 0:显示最新数据(最右边)minScrollX = width - totalWidth:确保最左边的数据刚好显示在屏幕左边界- 如果数据总宽度小于等于屏幕宽度,不需要滚动,返回
0f
在绘制时使用滚动位置:
在 onDraw 中,根据 scrollX 计算可见数据范围:
kotlin
override fun onDraw(canvas: Canvas) {
// ... 其他绘制逻辑 ...
// 根据滚动位置计算可见数据的起始索引
val baseStartIndex = (klineData.size - visibleCount).coerceAtLeast(0)
val scrollOffset = -scrollX // scrollX 是负值,取反得到正偏移量(像素)
val scrollIndex = (scrollOffset / totalCandleWidth).toInt()
val startIndex = (baseStartIndex - scrollIndex).coerceIn(0, (klineData.size - visibleCount).coerceAtLeast(0))
// 获取可见的K线数据
val visibleData = klineData.subList(startIndex, startIndex + visibleCount)
// 计算像素级偏移(用于平滑滚动)
val pixelOffset = scrollOffset % totalCandleWidth
// 绘制K线时使用 pixelOffset
visibleData.forEachIndexed { index, entity ->
drawCandle(canvas, entity, index, minPrice, maxPrice, height, pixelOffset)
}
}
逻辑说明:
scrollX = 0时,scrollOffset = 0,显示最新数据scrollX < 0时,scrollOffset > 0,显示更早的数据pixelOffset用于实现像素级平滑滚动
四、Case3 与 Case4 的区别
核心区别
| 功能 | Case3 | Case4 |
|---|---|---|
| 可见区间处理 | ✅ | ✅ |
| 背景网格 | ✅ | ✅ |
| 价格标签 | ✅ | ✅ |
| 时间标签 | ✅ | ✅ |
| 水平拖拽 | ❌ | ✅ |
| 快速滑动 | ❌ | ✅ |
继承关系
markdown
ScrollableKLineView(基类,提供拖拽功能)
└── KLineViewCase4(滚动:实现滚动边界计算)
五、性能优化
invalidate() vs postInvalidateOnAnimation()
在滚动过程中,我们需要频繁触发重绘。选择合适的重绘方法对性能至关重要。
invalidate() 的特点
工作原理:
- 立即将当前View标记为无效
- 请求系统在下一帧重绘
- 同步调用,会立即加入重绘队列
执行时机:
- 调用后立即加入重绘队列
- 如果同一帧内多次调用,可能会合并为一次重绘
- 不保证与动画帧率同步
性能影响:
- 如果滚动过程中频繁调用,可能导致:
-
- 同一帧内多次重绘请求
- 重绘频率超过屏幕刷新率(60fps)
- 造成不必要的性能开销
postInvalidateOnAnimation() 的特点
工作原理:
- 在下一帧动画开始前将View标记为无效
- 与系统的动画帧率同步
- 异步调用,自动与屏幕刷新率对齐
执行时机:
- 在下一帧动画开始时执行
- 自动与屏幕刷新率(通常60fps)同步
- 如果同一帧内多次调用,会自动合并
性能优势:
- 与动画帧率同步,避免过度重绘
- 自动合并同一帧内的多次调用
- 滚动动画更流畅,性能更好
效果
``
