Candlestick Chart Implementation in Mobile Exchange App
Candlestick chart is technically the most complex chart type for mobile app. Not because of OHLC math, but because of the set of requirements that are mandatory for exchange UX: smooth zoom/pan across 10,000+ candles, crosshair with coordinates on touch, real-time update without redrawing entire chart, timeframe switching without flicker, correct behavior in landscape and portrait orientation. All these together — and most ready libraries start breaking.
Why Standard Libraries Don't Work
fl_chart — no candlestick out of box. Can assemble from custom BarChartRod, but it's a hack without zoom and acceptable performance.
syncfusion_flutter_charts — has SfCartesianChart with CandleSeries, supports zoom/pan, data updates. Workable for most tasks. But commercial license ($995+/year per developer) makes it impractical for startups.
TradingView Lightweight Charts in WebView — most common approach in production large exchange apps. Library written for production trading UI, optimized for large data, supports all needed features. WebView overhead exists but negligible on modern devices.
Native implementation via Canvas — CustomPainter (Flutter) or CALayer (iOS) or Canvas (Android). Maximum performance, full control. Requires significant development. Justified if candlestick is product's central element.
Native Implementation on Flutter via CustomPainter
For app where chart is main screen, native implementation gives 60fps on any device. Key components:
Data Structure
class Candle {
final int timestamp; // Unix timestamp in ms
final double open;
final double high;
final double low;
final double close;
final double volume;
bool get isBullish => close >= open;
}
Render Pipeline
CustomPainter with shouldRepaint — called on any data change. To avoid redrawing entire chart on new candle:
class CandlestickPainter extends CustomPainter {
final List<Candle> candles;
final CandleChartController controller; // stores offset and 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 (shadow)
final highY = range.toY(c.high, height);
final lowY = range.toY(c.low, height);
canvas.drawLine(Offset(x, highY), Offset(x, lowY), paint);
// Body
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 for 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 and Pinch-Zoom
GestureDetector with onScaleStart/Update for pinch-zoom, onPanUpdate for time axis scroll:
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 is important: without limits user goes into one-candle-fills-screen mode.
Crosshair on Long Press
GestureDetector(
onLongPressStart: (details) {
controller.crosshairVisible = true;
controller.crosshairPosition = details.localPosition;
// Calculate nearest candle to touch position
final candleIndex = controller.positionToIndex(details.localPosition.dx, candles.length);
if (candleIndex < candles.length) {
_showCandleInfo(candles[candleIndex]);
}
},
onLongPressMoveUpdate: (details) {
controller.crosshairPosition = details.localPosition;
// update info panel
},
onLongPressEnd: (_) => controller.crosshairVisible = false,
)
Real-Time Last Candle Update
WebSocket connection to exchange gives tick data. On new tick — update only last candle, don't rebuild entire list:
void onTickReceived(Tick tick) {
if (_candles.isEmpty) return;
final last = _candles.last;
// Update OHLC of last candle
_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,
);
// New timeframe — new candle
if (tick.timestamp >= last.timestamp + timeframe.milliseconds) {
_candles.add(Candle.fromTick(tick));
if (_candles.length > maxCandlesInMemory) _candles.removeAt(0);
}
// Notify only painter, not entire screen
_chartController.notifyListeners();
}
ValueNotifier + ValueListenableBuilder only around CustomPaint — only canvas redraws, not AppBar and side panels.
Timeframe Switching
Buttons 1m / 5m / 15m / 1h / 4h / 1D / 1W. On switch — request new candle set, apply crossfade animation to remove "flicker":
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: CandlestickWidget(
key: ValueKey(selectedTimeframe), // on key change — animation
candles: _candles,
),
)
Technical Indicators (Optional)
MA (Moving Average), EMA, Bollinger Bands, RSI — each drawn as additional layer in CustomPainter. RSI and MACD — on separate bottom CustomPaint with fixed height and shared horizontal time axis with main chart.
Scope of Work
- Candlestick CustomPainter implementation or TradingView integration in WebView
- Pan/zoom gestures with proper limits
- Crosshair with info panel
- Real-time update via WebSocket
- Timeframe switching
- Technical indicators (MA, EMA, Bollinger Bands — by agreement)
- Volume bars on bottom panel
Timeframe
WebView + TradingView Lightweight Charts: 1–2 weeks (including WebSocket integration). Native CustomPainter with full exchange chart functionality: 3–5 weeks. Cost calculated individually.







