Реализация графика свечей (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 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 недель. Стоимость рассчитывается индивидуально.







