fix(kline-chart): improve pinch-to-zoom and fullscreen display

- Refactor to pixel-based scrolling system for smoother interaction
- Fix pinch-to-zoom to properly scale around focal point
- Adjust fullscreen layout to give more space to main chart (65%)
- Add candleWidth parameter to all painters for consistent rendering
- Detect multi-touch gestures using pointerCount

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-16 09:49:14 -08:00
parent 0ebb0ad076
commit 27bf67e561
4 changed files with 130 additions and 93 deletions

View File

@ -42,17 +42,18 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
static const Color _grayText = Color(0xFF6B7280);
static const Color _borderGray = Color(0xFFE5E7EB);
//
double _scale = 1.0;
double _prevScale = 1.0;
double _offsetX = 0.0;
double _startOffsetX = 0.0;
// - candleWidth的缩放模式
double _candleWidth = 8.0; // K线宽度
double _prevCandleWidth = 8.0;
double _scrollX = 0.0; //
double _startScrollX = 0.0;
Offset? _startFocalPoint;
bool _isScaling = false;
double _chartWidth = 0.0; //
// K线数量范围
int _visibleCandleCount = 60;
static const int _minVisibleCandles = 20;
static const int _maxVisibleCandles = 200;
// K线宽度范围
static const double _minCandleWidth = 3.0;
static const double _maxCandleWidth = 30.0;
//
int _selectedMainIndicator = 0; // 0: MA, 1: EMA, 2: BOLL
@ -72,13 +73,13 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
}
void _scrollToEnd() {
if (widget.klines.isEmpty) return;
final totalCandles = widget.klines.length;
if (totalCandles > _visibleCandleCount) {
setState(() {
_offsetX = (totalCandles - _visibleCandleCount).toDouble();
});
}
if (widget.klines.isEmpty || _chartWidth == 0) return;
//
final totalWidth = widget.klines.length * _candleWidth;
final maxScroll = math.max(0.0, totalWidth - _chartWidth);
setState(() {
_scrollX = maxScroll;
});
}
@override
@ -140,8 +141,7 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
icon: const Icon(Icons.refresh, color: _orange),
onPressed: () {
setState(() {
_scale = 1.0;
_visibleCandleCount = 60;
_candleWidth = 8.0;
_scrollToEnd();
});
},
@ -282,6 +282,9 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
final chartHeight = height ?? constraints.maxHeight;
final chartWidth = constraints.maxWidth;
//
_chartWidth = chartWidth;
if (widget.klines.isEmpty) {
return Center(
child: Column(
@ -295,8 +298,8 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
);
}
//
final visibleData = _getVisibleData();
//
final visibleData = _getVisibleData(chartWidth);
//
final processor = KlineDataProcessor(widget.klines);
@ -307,15 +310,15 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
final bollData = processor.calculateBOLL();
final emaData = processor.calculateEMA([5, 10, 20]);
//
// -
final mainChartHeight = widget.showVolume
? (widget.isFullScreen ? chartHeight * 0.5 : chartHeight * 0.6)
: chartHeight * 0.7;
? (widget.isFullScreen ? chartHeight * 0.65 : chartHeight * 0.6)
: (widget.isFullScreen ? chartHeight * 0.75 : chartHeight * 0.7);
final volumeHeight = widget.showVolume
? (widget.isFullScreen ? chartHeight * 0.15 : chartHeight * 0.2)
? (widget.isFullScreen ? chartHeight * 0.12 : chartHeight * 0.2)
: 0.0;
final indicatorHeight = widget.isFullScreen
? chartHeight * 0.25
? (widget.showVolume ? chartHeight * 0.23 : chartHeight * 0.25)
: (widget.showVolume ? chartHeight * 0.2 : chartHeight * 0.3);
return Column(
@ -333,6 +336,7 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
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,
),
),
// 线
@ -371,6 +375,7 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
painter: KlineVolumePainter(
klines: visibleData.klines,
crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1,
candleWidth: visibleData.candleWidth,
),
),
),
@ -385,6 +390,7 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
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,
),
),
),
@ -432,39 +438,49 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
);
}
//
// -
void _onScaleStart(ScaleStartDetails details) {
_prevScale = _scale;
_startOffsetX = _offsetX;
_prevCandleWidth = _candleWidth;
_startScrollX = _scrollX;
_startFocalPoint = details.focalPoint;
// pointerCount > 1
_isScaling = details.pointerCount > 1;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() {
//
if (details.scale != 1.0) {
final newScale = (_prevScale * details.scale).clamp(0.5, 3.0);
_scale = newScale;
// K线宽度
if (_isScaling || (details.scale - 1.0).abs() > 0.01) {
_isScaling = true;
final newCandleWidth = (_prevCandleWidth * details.scale).clamp(_minCandleWidth, _maxCandleWidth);
// K线数量
_visibleCandleCount = (60 / _scale).round().clamp(_minVisibleCandles, _maxVisibleCandles);
// K线位置不变
if (_startFocalPoint != null && _chartWidth > 0) {
final focalRatio = _startFocalPoint!.dx / _chartWidth;
final oldFocalIndex = (_startScrollX + _startFocalPoint!.dx) / _prevCandleWidth;
final newFocalX = oldFocalIndex * newCandleWidth;
_scrollX = (newFocalX - focalRatio * _chartWidth).clamp(
0.0,
math.max(0.0, widget.klines.length * newCandleWidth - _chartWidth),
);
}
_candleWidth = newCandleWidth;
}
//
if (_startFocalPoint != null) {
// - 使
if (!_isScaling && _startFocalPoint != null) {
final dx = details.focalPoint.dx - _startFocalPoint!.dx;
// K线
final candleShift = -dx / 10;
_offsetX = (_startOffsetX + candleShift).clamp(
0.0,
math.max(0.0, (widget.klines.length - _visibleCandleCount).toDouble()),
);
final totalWidth = widget.klines.length * _candleWidth;
final maxScroll = math.max(0.0, totalWidth - _chartWidth);
_scrollX = (_startScrollX - dx).clamp(0.0, maxScroll);
}
});
}
void _onScaleEnd(ScaleEndDetails details) {
_startFocalPoint = null;
_isScaling = false;
}
void _onLongPressStart(LongPressStartDetails details) {
@ -484,40 +500,46 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
}
void _updateCrossLine(Offset position) {
if (widget.klines.isEmpty) return;
if (widget.klines.isEmpty || _chartWidth == 0) return;
setState(() {
_showCrossLine = true;
// K线索引
final visibleData = _getVisibleData();
if (visibleData.klines.isEmpty) return;
final candleWidth = (MediaQuery.of(context).size.width - 32) / visibleData.klines.length;
final localIndex = (position.dx / candleWidth).floor().clamp(0, visibleData.klines.length - 1);
_crossLineIndex = visibleData.startIndex + localIndex;
// K线索引
final absoluteX = _scrollX + position.dx;
final index = (absoluteX / _candleWidth).floor().clamp(0, widget.klines.length - 1);
_crossLineIndex = index;
});
}
//
_VisibleData _getVisibleData() {
if (widget.klines.isEmpty) {
return _VisibleData(klines: [], startIndex: 0);
// -
_VisibleData _getVisibleData(double chartWidth) {
if (widget.klines.isEmpty || chartWidth == 0) {
return _VisibleData(klines: [], startIndex: 0, candleWidth: _candleWidth);
}
final int maxStart = math.max(0, widget.klines.length - _visibleCandleCount);
final int startIndex = _offsetX.floor().clamp(0, maxStart);
final int endIndex = math.min(startIndex + _visibleCandleCount, widget.klines.length);
// K线范围
final int startIndex = (_scrollX / _candleWidth).floor().clamp(0, widget.klines.length - 1);
final int visibleCount = (chartWidth / _candleWidth).ceil() + 1; // +1 K线可见
final int endIndex = math.min(startIndex + visibleCount, 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 + _visibleCandleCount, entry.value.length);
final endIndex = math.min(startIndex + visibleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
@ -526,9 +548,10 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
}
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 + _visibleCandleCount, entry.value.length);
final endIndex = math.min(startIndex + visibleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
@ -537,9 +560,10 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
}
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 + _visibleCandleCount, entry.value.length);
final endIndex = math.min(startIndex + visibleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
@ -548,9 +572,10 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
}
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 + _visibleCandleCount, entry.value.length);
final endIndex = math.min(startIndex + visibleCount, entry.value.length);
if (startIndex < entry.value.length) {
result[entry.key] = entry.value.sublist(startIndex, endIndex);
}
@ -559,7 +584,8 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
}
List<double?> _getVisibleRsiData(List<double?> fullData, int startIndex) {
final endIndex = math.min(startIndex + _visibleCandleCount, fullData.length);
final visibleCount = _getVisibleCount();
final endIndex = math.min(startIndex + visibleCount, fullData.length);
if (startIndex < fullData.length) {
return fullData.sublist(startIndex, endIndex);
}
@ -686,6 +712,7 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
class _VisibleData {
final List<Kline> klines;
final int startIndex;
final double candleWidth;
_VisibleData({required this.klines, required this.startIndex});
_VisibleData({required this.klines, required this.startIndex, required this.candleWidth});
}

View File

@ -9,6 +9,7 @@ class KlineIndicatorPainter extends CustomPainter {
final Map<String, List<double?>>? kdjData;
final List<double?>? rsiData;
final int crossLineIndex;
final double? candleWidth;
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
@ -34,6 +35,7 @@ class KlineIndicatorPainter extends CustomPainter {
this.kdjData,
this.rsiData,
this.crossLineIndex = -1,
this.candleWidth,
});
@override
@ -72,8 +74,8 @@ class KlineIndicatorPainter extends CustomPainter {
if (crossLineIndex >= 0) {
final dataLength = _getDataLength();
if (dataLength > 0 && crossLineIndex < dataLength) {
final candleWidth = chartWidth / dataLength;
final x = leftPadding + crossLineIndex * candleWidth + candleWidth / 2;
final actualCandleWidth = candleWidth ?? (chartWidth / dataLength);
final x = leftPadding + crossLineIndex * actualCandleWidth + actualCandleWidth / 2;
canvas.drawLine(
Offset(x, 0),
Offset(x, size.height),
@ -146,14 +148,14 @@ class KlineIndicatorPainter extends CustomPainter {
);
// MACD柱状图
final candleWidth = chartWidth / macd.length;
final barWidth = math.max(candleWidth * 0.6, 2.0);
final actualCandleWidth = candleWidth ?? (chartWidth / macd.length);
final barWidth = math.max(actualCandleWidth * 0.6, 2.0);
for (int i = 0; i < macd.length; i++) {
if (macd[i] == null) continue;
final value = macd[i]!;
final x = leftPadding + i * candleWidth + candleWidth / 2;
final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2;
final y = valueToY(value);
final color = value >= 0 ? _red : _green;
@ -173,10 +175,10 @@ class KlineIndicatorPainter extends CustomPainter {
}
// DIF线
_drawIndicatorLine(canvas, dif, _difColor, leftPadding, candleWidth, valueToY);
_drawIndicatorLine(canvas, dif, _difColor, leftPadding, actualCandleWidth, valueToY);
// DEA线
_drawIndicatorLine(canvas, dea, _deaColor, leftPadding, candleWidth, valueToY);
_drawIndicatorLine(canvas, dea, _deaColor, leftPadding, actualCandleWidth, valueToY);
//
_drawMACDLegend(canvas, size, leftPadding, dif, dea, macd);
@ -234,16 +236,16 @@ class KlineIndicatorPainter extends CustomPainter {
}
}
final candleWidth = chartWidth / k.length;
final actualCandleWidth = candleWidth ?? (chartWidth / k.length);
// K线
_drawIndicatorLine(canvas, k, _kColor, leftPadding, candleWidth, valueToY);
_drawIndicatorLine(canvas, k, _kColor, leftPadding, actualCandleWidth, valueToY);
// D线
_drawIndicatorLine(canvas, d, _dColor, leftPadding, candleWidth, valueToY);
_drawIndicatorLine(canvas, d, _dColor, leftPadding, actualCandleWidth, valueToY);
// J线
_drawIndicatorLine(canvas, j, _jColor, leftPadding, candleWidth, valueToY);
_drawIndicatorLine(canvas, j, _jColor, leftPadding, actualCandleWidth, valueToY);
//
_drawKDJLegend(canvas, size, leftPadding, k, d, j);
@ -301,10 +303,10 @@ class KlineIndicatorPainter extends CustomPainter {
oversoldPaint,
);
final candleWidth = chartWidth / rsiData!.length;
final actualCandleWidth = candleWidth ?? (chartWidth / rsiData!.length);
// RSI线
_drawIndicatorLine(canvas, rsiData!, _rsiColor, leftPadding, candleWidth, valueToY);
_drawIndicatorLine(canvas, rsiData!, _rsiColor, leftPadding, actualCandleWidth, valueToY);
//
_drawRSILegend(canvas, size, leftPadding, rsiData!);
@ -497,6 +499,7 @@ class KlineIndicatorPainter extends CustomPainter {
oldDelegate.macdData != macdData ||
oldDelegate.kdjData != kdjData ||
oldDelegate.rsiData != rsiData ||
oldDelegate.crossLineIndex != crossLineIndex;
oldDelegate.crossLineIndex != crossLineIndex ||
oldDelegate.candleWidth != candleWidth;
}
}

View File

@ -10,6 +10,7 @@ class KlinePainter extends CustomPainter {
final Map<int, List<double?>>? emaData;
final Map<String, List<double?>>? bollData;
final int crossLineIndex;
final double? candleWidth; // K线宽度
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
@ -36,6 +37,7 @@ class KlinePainter extends CustomPainter {
this.emaData,
this.bollData,
this.crossLineIndex = -1,
this.candleWidth,
});
@override
@ -111,10 +113,10 @@ class KlinePainter extends CustomPainter {
//
_drawGrid(canvas, size, minPrice, maxPrice, leftPadding, rightPadding, topPadding, chartHeight);
// K线宽度
final candleWidth = chartWidth / klines.length;
final bodyWidth = math.max(candleWidth * 0.7, 2.0);
final gap = candleWidth * 0.15;
// K线宽度 - 使
final actualCandleWidth = candleWidth ?? (chartWidth / klines.length);
final bodyWidth = math.max(actualCandleWidth * 0.7, 2.0);
final gap = actualCandleWidth * 0.15;
// K线
for (int i = 0; i < klines.length; i++) {
@ -128,7 +130,7 @@ class KlinePainter extends CustomPainter {
final color = isUp ? _green : _red;
final paint = Paint()..color = color;
final x = leftPadding + i * candleWidth + candleWidth / 2;
final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2;
final yOpen = priceToY(open);
final yClose = priceToY(close);
final yHigh = priceToY(high);
@ -170,7 +172,7 @@ class KlinePainter extends CustomPainter {
entry.value,
_maColors[colorIndex % _maColors.length],
leftPadding,
candleWidth,
actualCandleWidth,
priceToY,
);
colorIndex++;
@ -186,7 +188,7 @@ class KlinePainter extends CustomPainter {
entry.value,
_maColors[colorIndex % _maColors.length],
leftPadding,
candleWidth,
actualCandleWidth,
priceToY,
);
colorIndex++;
@ -195,14 +197,14 @@ class KlinePainter extends CustomPainter {
// BOLL线
if (bollData != null) {
_drawLine(canvas, bollData!['middle']!, _bollMiddleColor, leftPadding, candleWidth, priceToY);
_drawLine(canvas, bollData!['upper']!, _bollUpperColor, leftPadding, candleWidth, priceToY, isDashed: true);
_drawLine(canvas, bollData!['lower']!, _bollLowerColor, leftPadding, candleWidth, priceToY, isDashed: true);
_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);
}
// 线
if (crossLineIndex >= 0 && crossLineIndex < klines.length) {
_drawCrossLine(canvas, size, crossLineIndex, leftPadding, candleWidth, priceToY);
_drawCrossLine(canvas, size, crossLineIndex, leftPadding, actualCandleWidth, priceToY);
}
// MA图例
@ -432,6 +434,7 @@ class KlinePainter extends CustomPainter {
oldDelegate.maData != maData ||
oldDelegate.emaData != emaData ||
oldDelegate.bollData != bollData ||
oldDelegate.crossLineIndex != crossLineIndex;
oldDelegate.crossLineIndex != crossLineIndex ||
oldDelegate.candleWidth != candleWidth;
}
}

View File

@ -7,6 +7,7 @@ import '../../../domain/entities/kline.dart';
class KlineVolumePainter extends CustomPainter {
final List<Kline> klines;
final int crossLineIndex;
final double? candleWidth;
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
@ -16,6 +17,7 @@ class KlineVolumePainter extends CustomPainter {
KlineVolumePainter({
required this.klines,
this.crossLineIndex = -1,
this.candleWidth,
});
@override
@ -72,8 +74,8 @@ class KlineVolumePainter extends CustomPainter {
maxVolText.paint(canvas, Offset(size.width - rightPadding + 4, topPadding));
//
final candleWidth = chartWidth / klines.length;
final barWidth = math.max(candleWidth * 0.7, 2.0);
final actualCandleWidth = candleWidth ?? (chartWidth / klines.length);
final barWidth = math.max(actualCandleWidth * 0.7, 2.0);
for (int i = 0; i < klines.length; i++) {
final kline = klines[i];
@ -84,7 +86,7 @@ class KlineVolumePainter extends CustomPainter {
final isUp = close >= open;
final color = isUp ? _green : _red;
final x = leftPadding + i * candleWidth + candleWidth / 2;
final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2;
final barHeight = (volume / maxVolume) * chartHeight;
final y = size.height - bottomPadding - barHeight;
@ -99,7 +101,7 @@ class KlineVolumePainter extends CustomPainter {
// 线
if (crossLineIndex >= 0 && crossLineIndex < klines.length) {
final x = leftPadding + crossLineIndex * candleWidth + candleWidth / 2;
final x = leftPadding + crossLineIndex * actualCandleWidth + actualCandleWidth / 2;
canvas.drawLine(
Offset(x, 0),
Offset(x, size.height),
@ -131,6 +133,8 @@ class KlineVolumePainter extends CustomPainter {
@override
bool shouldRepaint(covariant KlineVolumePainter oldDelegate) {
return oldDelegate.klines != klines || oldDelegate.crossLineIndex != crossLineIndex;
return oldDelegate.klines != klines ||
oldDelegate.crossLineIndex != crossLineIndex ||
oldDelegate.candleWidth != candleWidth;
}
}