Реалізація графіка свічок (Candlestick Chart) у мобільному приложенні біржі
Candlestick chart — технічно найскладніший тип графіка для мобільного приложення. Не через математику OHLC, а через набір вимог, які обов'язкові для біржевого UX: плавний zoom/pan по 10 000+ свічам, crosshair з координатами при касанні, оновлення в реальному часі без перерисовки всього графіка, переключення таймфреймів без мигання, коректна робота в ландшафтній та портретній орієнтації. Усі ці вимоги разом — і більшість готових бібліотек починають ломатися.
Чому стандартні бібліотеки не підходять
fl_chart — немає candlestick з коробки. Можна зібрати з кастомних BarChartRod, але це костиль без zoom та прийнятної продуктивності.
syncfusion_flutter_charts — є SfCartesianChart з CandleSeries, підтримує zoom/pan, оновлення даних. Це робочий варіант для більшості завдань. Але комерційна ліцензія ($995+/рік для одного розробника) робить його нецільовим для стартапів.
TradingView Lightweight Charts у WebView — найпоширеніший підхід у production-приложеннях крупних бирж. Бібліотека написана для production trading UI, оптимізована для великих обсягів даних, підтримує всі потрібні можливості. Overhead від WebView — є, але на сучасних пристроях незначний.
Нативна реалізація через Canvas — CustomPainter (Flutter) або CALayer (iOS) або Canvas (Android). Максимальна продуктивність, повний контроль. Вимагає значної розробки. Оправдано, якщо candlestick — центральний елемент продукту.
Нативна реалізація на Flutter через CustomPainter
Для приложення, де chart — головний екран, нативна реалізація дає 60fps на будь-якому пристрої. Ключові компоненти:
Структура даних
class Candle {
final int timestamp; // Unix timestamp у ms
final double open;
final double high;
final double low;
final double close;
final double volume;
bool get isBullish => close >= open;
}
Render pipeline
CustomPainter з shouldRepaint — викликається при кожній зміні даних. Щоб не перерисовувати весь chart при отриманні нової свічі:
class CandlestickPainter extends CustomPainter {
final List<Candle> candles;
final CandleChartController controller; // зберігає offset та scale
@override
void paint(Canvas canvas, Size size) {
final visibleRange = controller.getVisibleRange(candles.length, size.width);
final visibleCandles = candles.sublist(visibleRange.start, visibleRange.end);
final priceRange = _calculatePriceRange(visibleCandles);
final candleWidth = size.width / visibleCandles.length * controller.scale;
for (var i = 0; i < visibleCandles.length; i++) {
_drawCandle(canvas, visibleCandles[i], i, candleWidth, size.height, priceRange);
}
if (controller.crosshairVisible) {
_drawCrosshair(canvas, controller.crosshairPosition, size);
}
}
void _drawCandle(Canvas canvas, Candle c, int index, double width, double height, PriceRange range) {
final x = index * width + width / 2;
final paint = Paint()
..color = c.isBullish ? const Color(0xFF26A69A) : const Color(0xFFEF5350)
..strokeWidth = 1.5;
// Фитіль (wick)
final highY = range.toY(c.high, height);
final lowY = range.toY(c.low, height);
canvas.drawLine(Offset(x, highY), Offset(x, lowY), paint);
// Тело свічи
final openY = range.toY(c.open, height);
final closeY = range.toY(c.close, height);
final bodyPaint = Paint()..color = paint.color;
final bodyRect = Rect.fromLTRB(
x - width * 0.35, min(openY, closeY),
x + width * 0.35, max(openY, closeY),
);
// Для hollow candles (контур для bullish):
if (c.isBullish) {
canvas.drawRect(bodyRect, bodyPaint..style = PaintingStyle.stroke);
} else {
canvas.drawRect(bodyRect, bodyPaint..style = PaintingStyle.fill);
}
}
@override
bool shouldRepaint(CandlestickPainter old) =>
old.candles != candles || old.controller != controller;
}
Gesture handling: pan та pinch-zoom
GestureDetector з onScaleStart/Update для pinch-zoom, onPanUpdate для скролу по часовій осі:
GestureDetector(
onScaleUpdate: (details) {
setState(() {
controller.scale = (controller.scale * details.scale).clamp(0.5, 10.0);
controller.offset += details.focalPointDelta.dx;
});
},
child: CustomPaint(painter: CandlestickPainter(candles, controller)),
)
Clamp scale — важливо: без обмеження користувач уйде в режим, де одна свіча займає весь екран.
Crosshair при long press
GestureDetector(
onLongPressStart: (details) {
controller.crosshairVisible = true;
controller.crosshairPosition = details.localPosition;
// Обчислюємо найближчу свічу до позиції касання
final candleIndex = controller.positionToIndex(details.localPosition.dx, candles.length);
if (candleIndex < candles.length) {
_showCandleInfo(candles[candleIndex]);
}
},
onLongPressMoveUpdate: (details) {
controller.crosshairPosition = details.localPosition;
// оновлюємо інформаційну панель
},
onLongPressEnd: (_) => controller.crosshairVisible = false,
)
Real-time оновлення останної свічи
WebSocket підключення до біржі дає tick-дані. При отриманні нового тика — оновлюємо тільки останню свічу, не перебудовуємо весь список:
void onTickReceived(Tick tick) {
if (_candles.isEmpty) return;
final last = _candles.last;
// Оновлюємо OHLC останної свічи
_candles[_candles.length - 1] = last.copyWith(
high: max(last.high, tick.price),
low: min(last.low, tick.price),
close: tick.price,
volume: last.volume + tick.volume,
);
// Новий таймфрейм — нова свіча
if (tick.timestamp >= last.timestamp + timeframe.milliseconds) {
_candles.add(Candle.fromTick(tick));
if (_candles.length > maxCandlesInMemory) _candles.removeAt(0);
}
// Notify тільки painter, не весь екран
_chartController.notifyListeners();
}
ValueNotifier + ValueListenableBuilder тільки навколо CustomPaint — перерисовується тільки canvas, не AppBar та бічні панелі.
Переключення таймфреймів
Кнопки 1m / 5m / 15m / 1h / 4h / 1D / 1W. При переключенні — запитуємо новий набір свічей, застосовуємо crossfade-анімацію, щоб убрати «мигання»:
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: CandlestickWidget(
key: ValueKey(selectedTimeframe), // при смене ключа — анімація
candles: _candles,
),
)
Технічні індикатори (опціонально)
MA (Moving Average), EMA, Bollinger Bands, RSI — кожен рисується як додатковий шар у CustomPainter. RSI та MACD — на окремому нижньому CustomPaint з фіксованою висотою та спільною горизонтальною віссю часу з основним графіком.
Що входить у роботу
- Реалізація candlestick CustomPainter або інтеграція TradingView у WebView
- Pan/zoom жести з правильними обмеженнями
- Crosshair з інформаційною панеллю
- Real-time оновлення через WebSocket
- Переключення таймфреймів
- Технічні індикатори (MA, EMA, Bollinger Bands — за узгодженням)
- Volume bars на нижній панелі
Строки
WebView + TradingView Lightweight Charts: 1–2 тижні (включаючи WebSocket-інтеграцію). Нативний CustomPainter з повним функціоналом біржевого графіка: 3–5 тижнів. Вартість розраховується індивідуально.







