feat(kline): implement true pan effect with scrollOffset

- Added scrollOffset parameter to KlinePainter and KlineVolumePainter
- Painters now draw all klines and apply scrollOffset for positioning
- Added canvas clipping to prevent drawing outside chart bounds
- Removed _getVisibleData and related helper methods (no longer needed)
- scrollOffset directly controls the visual position of all klines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-19 21:43:45 -08:00
parent 48ba72ce89
commit f55fb13f26
3 changed files with 106 additions and 155 deletions

View File

@ -94,23 +94,29 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
///
///
/// - K线从左边开始排列
/// - <=
/// -
/// - K线在屏幕中心
void _scrollToCenter() {
if (widget.klines.isEmpty || _chartWidth == 0) return;
// K线
final int halfScreenCount = (_chartWidth / 2 / _candleWidth).ceil();
// painter padding
const leftPadding = 8.0;
const rightPadding = 50.0;
final drawableWidth = _chartWidth - leftPadding - rightPadding;
if (widget.klines.length <= halfScreenCount) {
//
// K线的总宽度
final totalKlineWidth = widget.klines.length * _candleWidth;
if (totalKlineWidth <= drawableWidth) {
//
_scrollX = 0;
} else {
// 使K线在屏幕中心
// startIndex = klines.length - halfScreenCount
// scrollX = startIndex * candleWidth
final int startIndex = widget.klines.length - halfScreenCount;
_scrollX = startIndex * _candleWidth;
// K线的中心位置leftPadding开始= (N-0.5) * candleWidth
// drawableWidth/2
// scrollOffset = (N-0.5) * candleWidth - drawableWidth/2
_scrollX = (widget.klines.length - 0.5) * _candleWidth - drawableWidth / 2;
_scrollX = math.max(0.0, _scrollX);
}
}
@ -353,9 +359,6 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
);
}
//
final visibleData = _getVisibleData(chartWidth);
//
final processor = KlineDataProcessor(widget.klines);
final maData = processor.calculateMA([5, 10, 20, 60]);
@ -386,24 +389,24 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
CustomPaint(
size: Size(chartWidth, mainChartHeight),
painter: KlinePainter(
klines: visibleData.klines,
maData: _selectedMainIndicator == 0 ? _getVisibleMAData(maData, visibleData.startIndex) : null,
emaData: _selectedMainIndicator == 1 ? _getVisibleMAData(emaData, visibleData.startIndex) : null,
bollData: _selectedMainIndicator == 2 ? _getVisibleBollData(bollData, visibleData.startIndex) : null,
crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1,
candleWidth: visibleData.candleWidth,
klines: widget.klines,
maData: _selectedMainIndicator == 0 ? maData : null,
emaData: _selectedMainIndicator == 1 ? emaData : null,
bollData: _selectedMainIndicator == 2 ? bollData : null,
crossLineIndex: _showCrossLine ? _crossLineIndex : -1,
candleWidth: _candleWidth,
isDark: AppColors.isDark(context),
scrollOffset: _scrollX,
),
),
// 线
if (_showCrossLine && _crossLineIndex >= 0 && _crossLineIndex < widget.klines.length)
_buildCrossLineInfo(visibleData),
_buildCrossLineInfo(),
//
if (visibleData.klines.isNotEmpty)
if (widget.klines.isNotEmpty)
Positioned(
right: 0,
top: _calcPriceY(
visibleData,
double.tryParse(widget.currentPrice) ?? 0,
mainChartHeight,
),
@ -429,10 +432,11 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
child: CustomPaint(
size: Size(chartWidth, volumeHeight),
painter: KlineVolumePainter(
klines: visibleData.klines,
crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1,
candleWidth: visibleData.candleWidth,
klines: widget.klines,
crossLineIndex: _showCrossLine ? _crossLineIndex : -1,
candleWidth: _candleWidth,
isDark: AppColors.isDark(context),
scrollOffset: _scrollX,
),
),
),
@ -443,11 +447,12 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
// size: Size(chartWidth, indicatorHeight),
// painter: KlineIndicatorPainter(
// indicatorType: _selectedSubIndicator,
// macdData: _selectedSubIndicator == 0 ? _getVisibleMacdData(macdData, visibleData.startIndex) : null,
// kdjData: _selectedSubIndicator == 1 ? _getVisibleKdjData(kdjData, visibleData.startIndex) : null,
// rsiData: _selectedSubIndicator == 2 ? _getVisibleRsiData(rsiData, visibleData.startIndex) : null,
// crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1,
// candleWidth: visibleData.candleWidth,
// macdData: _selectedSubIndicator == 0 ? macdData : null,
// kdjData: _selectedSubIndicator == 1 ? kdjData : null,
// rsiData: _selectedSubIndicator == 2 ? rsiData : null,
// crossLineIndex: _showCrossLine ? _crossLineIndex : -1,
// candleWidth: _candleWidth,
// scrollOffset: _scrollX,
// ),
// ),
// ),
@ -505,6 +510,11 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() {
// painter padding
const leftPadding = 8.0;
const rightPadding = 50.0;
final drawableWidth = _chartWidth - leftPadding - rightPadding;
//
if (_isScaling) {
final newCandleWidth = (_prevCandleWidth * details.scale).clamp(_minCandleWidth, _maxCandleWidth);
@ -514,8 +524,8 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
final oldFocalIndex = (_startScrollX + _startFocalPoint!.dx) / _prevCandleWidth;
final newFocalX = oldFocalIndex * newCandleWidth;
// maxScroll
final int halfScreenCount = (_chartWidth / 2 / newCandleWidth).ceil();
final double maxScroll = math.max(0.0, (widget.klines.length - halfScreenCount) * newCandleWidth);
final newDrawableWidth = _chartWidth - leftPadding - rightPadding;
final double maxScroll = math.max(0.0, (widget.klines.length - 0.5) * newCandleWidth - newDrawableWidth / 2);
_scrollX = (newFocalX - focalRatio * _chartWidth).clamp(0.0, maxScroll);
}
@ -525,13 +535,12 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
// dx > 0 dx < 0
final dx = details.focalPoint.dx - _startFocalPoint!.dx;
// K线在屏幕中心
final int halfScreenCount = (_chartWidth / 2 / _candleWidth).ceil();
final double maxScroll = math.max(0.0, (widget.klines.length - halfScreenCount) * _candleWidth);
final double maxScroll = math.max(0.0, (widget.klines.length - 0.5) * _candleWidth - drawableWidth / 2);
// dx>0scrollX
_scrollX = (_startScrollX - dx).clamp(0.0, maxScroll);
// DEBUG:
debugPrint('Pan: dx=$dx, scrollX=$_scrollX, maxScroll=$maxScroll, startIndex=${(_scrollX / _candleWidth).floor()}');
debugPrint('Pan: dx=$dx, scrollX=$_scrollX, maxScroll=$maxScroll');
}
});
}
@ -564,106 +573,17 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
_showCrossLine = true;
// K线索引
final absoluteX = _scrollX + position.dx;
final index = (absoluteX / _candleWidth).floor().clamp(0, widget.klines.length - 1);
// position.dx widget的位置
// painter i根K线的x坐标 = leftPadding + i * candleWidth + candleWidth/2 - scrollOffset
// i = (position.dx + scrollOffset - leftPadding - candleWidth/2) / candleWidth
// i = (position.dx + scrollOffset - leftPadding) / candleWidth - 0.5
const leftPadding = 8.0;
final index = ((position.dx + _scrollX - leftPadding) / _candleWidth).floor().clamp(0, widget.klines.length - 1);
_crossLineIndex = index;
});
}
// - K线数据
_VisibleData _getVisibleData(double chartWidth) {
if (widget.klines.isEmpty || chartWidth == 0) {
return _VisibleData(klines: [], startIndex: 0, candleWidth: _candleWidth);
}
// K线
final int fullScreenCount = (chartWidth / _candleWidth).ceil() + 1;
// K线
final int halfScreenCount = (chartWidth / 2 / _candleWidth).ceil();
//
if (widget.klines.length <= halfScreenCount) {
return _VisibleData(
klines: widget.klines,
startIndex: 0,
candleWidth: _candleWidth,
);
}
// _scrollX
final int startIndex = (_scrollX / _candleWidth).floor().clamp(0, math.max(0, widget.klines.length - 1));
final int endIndex = math.min(startIndex + fullScreenCount, widget.klines.length);
return _VisibleData(
klines: widget.klines.sublist(startIndex, endIndex),
startIndex: startIndex,
candleWidth: _candleWidth,
);
}
int _getVisibleCount() {
if (_chartWidth == 0) return 60;
return (_chartWidth / _candleWidth).ceil() + 1;
}
Map<int, List<double?>> _getVisibleMAData(Map<int, List<double?>> fullData, int startIndex) {
final visibleCount = _getVisibleCount();
final result = <int, List<double?>>{};
for (final entry in fullData.entries) {
final endIndex = math.min(startIndex + visibleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
}
return result;
}
Map<String, List<double?>> _getVisibleBollData(Map<String, List<double?>> fullData, int startIndex) {
final visibleCount = _getVisibleCount();
final result = <String, List<double?>>{};
for (final entry in fullData.entries) {
final endIndex = math.min(startIndex + visibleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
}
return result;
}
Map<String, List<double?>> _getVisibleMacdData(Map<String, List<double?>> fullData, int startIndex) {
final visibleCount = _getVisibleCount();
final result = <String, List<double?>>{};
for (final entry in fullData.entries) {
final endIndex = math.min(startIndex + visibleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
}
return result;
}
Map<String, List<double?>> _getVisibleKdjData(Map<String, List<double?>> fullData, int startIndex) {
final visibleCount = _getVisibleCount();
final result = <String, List<double?>>{};
for (final entry in fullData.entries) {
final endIndex = math.min(startIndex + visibleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
}
return result;
}
List<double?> _getVisibleRsiData(List<double?> fullData, int startIndex) {
final visibleCount = _getVisibleCount();
final endIndex = math.min(startIndex + visibleCount, fullData.length);
if (startIndex < fullData.length) {
return fullData.sublist(startIndex, endIndex);
}
return [];
}
Widget _buildCrossLineInfo(_VisibleData visibleData) {
Widget _buildCrossLineInfo() {
if (_crossLineIndex < 0 || _crossLineIndex >= widget.klines.length) {
return const SizedBox.shrink();
}
@ -759,12 +679,12 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
return '${time.month}/${time.day} ${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
double _calcPriceY(_VisibleData visibleData, double price, double chartHeight) {
if (visibleData.klines.isEmpty) return chartHeight / 2;
double _calcPriceY(double price, double chartHeight) {
if (widget.klines.isEmpty) return chartHeight / 2;
double minPrice = double.infinity;
double maxPrice = double.negativeInfinity;
for (final kline in visibleData.klines) {
for (final kline in widget.klines) {
final low = double.tryParse(kline.low) ?? 0;
final high = double.tryParse(kline.high) ?? 0;
if (low < minPrice) minPrice = low;
@ -785,11 +705,3 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
return y.clamp(0.0, chartHeight - 20);
}
}
class _VisibleData {
final List<Kline> klines;
final int startIndex;
final double candleWidth;
_VisibleData({required this.klines, required this.startIndex, required this.candleWidth});
}

View File

@ -12,6 +12,7 @@ class KlinePainter extends CustomPainter {
final int crossLineIndex;
final double? candleWidth; // K线宽度
final bool isDark; //
final double scrollOffset; //
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
@ -42,6 +43,7 @@ class KlinePainter extends CustomPainter {
this.crossLineIndex = -1,
this.candleWidth,
this.isDark = false,
this.scrollOffset = 0.0,
});
@override
@ -123,7 +125,10 @@ class KlinePainter extends CustomPainter {
// K线宽度 - 使
final actualCandleWidth = candleWidth ?? (chartWidth / klines.length);
final bodyWidth = math.max(actualCandleWidth * 0.7, 2.0);
final gap = actualCandleWidth * 0.15;
// K线超出边界
canvas.save();
canvas.clipRect(Rect.fromLTRB(leftPadding, 0, size.width - rightPadding, size.height));
// K线
for (int i = 0; i < klines.length; i++) {
@ -137,7 +142,14 @@ class KlinePainter extends CustomPainter {
final color = isUp ? _green : _red;
final paint = Paint()..color = color;
final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2;
// scrollOffset为正时K线向左移动
final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2 - scrollOffset;
// K线
if (x < leftPadding - actualCandleWidth || x > size.width - rightPadding + actualCandleWidth) {
continue;
}
final yOpen = priceToY(open);
final yClose = priceToY(close);
final yHigh = priceToY(high);
@ -170,7 +182,12 @@ class KlinePainter extends CustomPainter {
}
}
// MA线
canvas.restore();
// MA线
canvas.save();
canvas.clipRect(Rect.fromLTRB(leftPadding, 0, size.width - rightPadding, size.height));
if (maData != null) {
int colorIndex = 0;
for (final entry in maData!.entries) {
@ -181,6 +198,7 @@ class KlinePainter extends CustomPainter {
leftPadding,
actualCandleWidth,
priceToY,
scrollOffset,
);
colorIndex++;
}
@ -197,6 +215,7 @@ class KlinePainter extends CustomPainter {
leftPadding,
actualCandleWidth,
priceToY,
scrollOffset,
);
colorIndex++;
}
@ -204,14 +223,16 @@ class KlinePainter extends CustomPainter {
// BOLL线
if (bollData != null) {
_drawLine(canvas, bollData!['middle']!, _bollMiddleColor, leftPadding, actualCandleWidth, priceToY);
_drawLine(canvas, bollData!['upper']!, _bollUpperColor, leftPadding, actualCandleWidth, priceToY, isDashed: true);
_drawLine(canvas, bollData!['lower']!, _bollLowerColor, leftPadding, actualCandleWidth, priceToY, isDashed: true);
_drawLine(canvas, bollData!['middle']!, _bollMiddleColor, leftPadding, actualCandleWidth, priceToY, scrollOffset);
_drawLine(canvas, bollData!['upper']!, _bollUpperColor, leftPadding, actualCandleWidth, priceToY, scrollOffset, isDashed: true);
_drawLine(canvas, bollData!['lower']!, _bollLowerColor, leftPadding, actualCandleWidth, priceToY, scrollOffset, isDashed: true);
}
canvas.restore();
// 线
if (crossLineIndex >= 0 && crossLineIndex < klines.length) {
_drawCrossLine(canvas, size, crossLineIndex, leftPadding, actualCandleWidth, priceToY);
_drawCrossLine(canvas, size, crossLineIndex, leftPadding, actualCandleWidth, priceToY, scrollOffset);
}
// MA图例
@ -265,7 +286,8 @@ class KlinePainter extends CustomPainter {
Color color,
double leftPadding,
double candleWidth,
double Function(double) priceToY, {
double Function(double) priceToY,
double scrollOffset, {
bool isDashed = false,
}) {
final paint = Paint()
@ -278,7 +300,7 @@ class KlinePainter extends CustomPainter {
for (int i = 0; i < data.length; i++) {
if (data[i] != null) {
final x = leftPadding + i * candleWidth + candleWidth / 2;
final x = leftPadding + i * candleWidth + candleWidth / 2 - scrollOffset;
final y = priceToY(data[i]!);
if (!started) {
@ -322,12 +344,13 @@ class KlinePainter extends CustomPainter {
double leftPadding,
double candleWidth,
double Function(double) priceToY,
double scrollOffset,
) {
final paint = Paint()
..color = _crossLineColor
..strokeWidth = 0.5;
final x = leftPadding + index * candleWidth + candleWidth / 2;
final x = leftPadding + index * candleWidth + candleWidth / 2 - scrollOffset;
final kline = klines[index];
final close = double.tryParse(kline.close) ?? 0;
final y = priceToY(close);
@ -452,6 +475,7 @@ class KlinePainter extends CustomPainter {
oldDelegate.bollData != bollData ||
oldDelegate.crossLineIndex != crossLineIndex ||
oldDelegate.candleWidth != candleWidth ||
oldDelegate.isDark != isDark;
oldDelegate.isDark != isDark ||
oldDelegate.scrollOffset != scrollOffset;
}
}

View File

@ -9,6 +9,7 @@ class KlineVolumePainter extends CustomPainter {
final int crossLineIndex;
final double? candleWidth;
final bool isDark; //
final double scrollOffset; //
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
@ -23,6 +24,7 @@ class KlineVolumePainter extends CustomPainter {
this.crossLineIndex = -1,
this.candleWidth,
this.isDark = false,
this.scrollOffset = 0.0,
});
@override
@ -82,6 +84,10 @@ class KlineVolumePainter extends CustomPainter {
final actualCandleWidth = candleWidth ?? (chartWidth / klines.length);
final barWidth = math.max(actualCandleWidth * 0.7, 2.0);
//
canvas.save();
canvas.clipRect(Rect.fromLTRB(leftPadding, 0, size.width - rightPadding, size.height));
for (int i = 0; i < klines.length; i++) {
final kline = klines[i];
final volume = double.tryParse(kline.volume) ?? 0;
@ -91,7 +97,13 @@ class KlineVolumePainter extends CustomPainter {
final isUp = close >= open;
final color = isUp ? _green : _red;
final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2;
final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2 - scrollOffset;
//
if (x < leftPadding - actualCandleWidth || x > size.width - rightPadding + actualCandleWidth) {
continue;
}
final barHeight = (volume / maxVolume) * chartHeight;
final y = size.height - bottomPadding - barHeight;
@ -104,9 +116,11 @@ class KlineVolumePainter extends CustomPainter {
);
}
canvas.restore();
// 线
if (crossLineIndex >= 0 && crossLineIndex < klines.length) {
final x = leftPadding + crossLineIndex * actualCandleWidth + actualCandleWidth / 2;
final x = leftPadding + crossLineIndex * actualCandleWidth + actualCandleWidth / 2 - scrollOffset;
canvas.drawLine(
Offset(x, 0),
Offset(x, size.height),
@ -141,6 +155,7 @@ class KlineVolumePainter extends CustomPainter {
return oldDelegate.klines != klines ||
oldDelegate.crossLineIndex != crossLineIndex ||
oldDelegate.candleWidth != candleWidth ||
oldDelegate.isDark != isDark;
oldDelegate.isDark != isDark ||
oldDelegate.scrollOffset != scrollOffset;
}
}