DEV Community

Cover image for Ever Wonder How Flutter Powers a Smooth, Live Price List for Stocks and Crypto?
Twilight
Twilight

Posted on

Ever Wonder How Flutter Powers a Smooth, Live Price List for Stocks and Crypto?

Have you ever scrolled through a list of live prices—stocks 📈 (like Apple or Tesla), crypto pairs 💰 (like BTC/USDT or ETH/BUSD)—and wondered how it stays smooth with all that data flying in? I’ve been curious about that too. Showing hundreds or even thousands of assets updating in real time, whether it’s the steady pace of stocks or crypto’s wild swings, feels like a big challenge.

I decided to take a stab at it in Flutter, using Binance’s crypto ticker stream as my sandbox. I’m not saying I’ve cracked the perfect solution, but here’s how I tried to answer that question—hopefully it’s helpful to someone out there. 🙏


Step 1: Selective Updates That Don’t Break the App

Why It’s Tricky: Don’t Redraw Everything

Imagine a list of 2000 crypto pairs, and every price tick—say, 10 per second—redraws the whole thing. In Flutter, a naive setState would tank performance faster than you can say “lag.” I needed updates to hit only what changed, not the entire screen.

How I Approached It: Targeting Specific Changes

First, I modeled each crypto pair and its live data. Here’s the MarketSymbol for structure:

class MarketSymbol {
  final String id; // e.g., "BTCUSDT"
  final String baseAsset; // "BTC"
  final String quoteAsset; // "USDT"
  final SymbolSnapshot? snapshot; // Live price stats
  MarketSymbol({required this.id, required this.baseAsset, required this.quoteAsset, this.snapshot});
}
Enter fullscreen mode Exit fullscreen mode

And SymbolSnapshot for the latest stats:

class SymbolSnapshot {
  final String symbol;
  final double lastPrice; // Latest price
  final double priceChangePercent; // 24hr change
  // ... openPrice, highPrice, etc.
}
Enter fullscreen mode Exit fullscreen mode

Binance sends updates via a ticker stream, captured in SymbolTickerEvent:

class SymbolTickerEvent {
  final String eventType; // "24hrTicker"
  final int eventTime; // Timestamp
  final String symbol; // "BTCUSDT"
  final double closePrice; // Latest price
  final double openPrice;
  final double highPrice;
  final double lowPrice;
  final double totalTradedBaseAssetVolume; // Base volume
  final double totalTradedQuoteAssetVolume; // Quote volume

  SymbolSnapshot get snapshot => SymbolSnapshot(
    symbol: symbol,
    lastPrice: closePrice,
    priceChangePercent: (closePrice - openPrice) / openPrice * 100,
    // ... other fields
  );
}
Enter fullscreen mode Exit fullscreen mode

Using Riverpod in Flutter, I stored symbols in a Map and updated only what changed:

void _onSymbolTickerEvent(SymbolTickerEvent event) {
  final symbol = state.symbolMap[event.symbol];
  if (symbol != null && symbol.snapshot?.lastPrice != event.snapshot.lastPrice) {
    state = state.copyWith(
      symbolMap: {
        ...state.symbolMap,
        symbol.id: symbol.copyWith(snapshot: event.snapshot),
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

In the UI, I hook into Riverpod’s select to watch just that symbol:

class MarketSymbolRow extends ConsumerWidget {
  const MarketSymbolRow({required this.symbolId});
  static const double height = 60;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final symbol = ref.watch(marketVMProvider.select((state) => state.symbolMap[symbolId]!));
    // Returns fancy UI with a height of 60 px
  }
}
Enter fullscreen mode Exit fullscreen mode

It’s not fancy—just a way to keep updates small. When “BTCUSDT” or "ETHUSDT” changes, only its row refreshes, which felt like a decent start.


Step 2: Just Listen to What You See

Why It’s Tricky: Too Much Data

Binance streams data for every pair, but why grab “XRPUSDT” if it’s 300 rows off-screen? Subscribing to all 2000+ pairs would choke the network. I needed to focus on what’s visible.

How I Approached It: Watching the Viewport

In Flutter, I used a ScrollController and a GlobalKey to figure out what’s visible:

void _updateVisibleSymbols() {
  final symbolIds = ref.read(marketVMProvider.select((state) => state.symbolIds));

  final viewportSymbolRows = (_viewportKey.height / MarketSymbolRow.height).ceil(); // Visible rows
  final firstIndex = max(_scrollController.offset ~/ MarketSymbolRow.height, 0); // Top row
  final lastIndex = min(firstIndex + viewportSymbolRows, symbolIds.length - 1); // Bottom row
  final newVisibleSymbolIds = symbolIds.toList().sublist(firstIndex, lastIndex + 1).toSet();

  if (newVisibleSymbolIds.difference(_visibleSymbolIds).isNotEmpty) {
    _visibleSymbolIds = newVisibleSymbolIds;
    ref.read(marketVMProvider.notifier).syncPriceTracking(_visibleSymbolIds);
  }
}
Enter fullscreen mode Exit fullscreen mode

The view model handles subscriptions, unsubscribes from symbols that are no longer needed and subscribes only to new ones. Overlapping symbols (already subscribed) are ignored to avoid redundant operations.:

void syncPriceTracking(Set<String> symbolIds) async {
  final symbolIdsToUnsubscribe = _currentSubscribedSymbolIds.where((s) => !symbolIds.contains(s)).toList();
  final symbolIdsToSubscribe = symbolIds.where((s) => !_currentSubscribedSymbolIds.contains(s)).toList();

  if (symbolIdsToUnsubscribe.isNotEmpty) _marketRepository.unsubscribeSymbolMiniTickerStream(symbols: symbolIdsToUnsubscribe);
  if (symbolIdsToSubscribe.isNotEmpty) {
    await _marketRepository.subscribeSymbolMiniTickerStream(symbols: symbolIdsToSubscribe);
    _currentSubscribedSymbolIds = symbolIds;
  }
}
Enter fullscreen mode Exit fullscreen mode

This kept it simple—streaming maybe 15-20 assets instead of 500. It’s not perfect, but it worked for my little experiment. (WebSocket details? See my other post)


Step 3: Scroll Without the Stutter

Why It’s Tricky: Laggy Scrolling

Even with those tweaks, scrolling felt rough because I was checking the viewport too often. I wanted it to feel smooth, even with all that live data.

How I Approached It: Giving It a Breather and Fixed Sizes

I added a debounce timer to calm things down:

void _onScroll() {
  _debounceTimer?.cancel();
  if ((_scrollController.offset - _lastOffset).abs() < 30) return; // Skip small nudges

  _debounceTimer = Timer(Duration(milliseconds: 300), () {
    _lastOffset = _scrollController.offset;
    _updateVisibleSymbols(); // Update after a breather
  });
}
Enter fullscreen mode Exit fullscreen mode

And built the list with a fixed itemExtent in Flutter’s ListView.builder:

ListView.builder(
  key: _viewportKey,
  controller: _scrollController,
  itemExtent: MarketSymbolRow.height, // Locked at 60px
  itemCount: symbolIds.length,
  itemBuilder: (context, index) {
    final symbolId = symbolIds[index];
    return MarketSymbolRow(symbolId: symbolId);
  },
)
Enter fullscreen mode Exit fullscreen mode

The debounce waits 300ms after scrolling stops, and itemExtent keeps layout quick. It’s a small trick, but it made scrolling nicer for me.


What I Took Away

This was just my attempt to wrestle with real-time data in Flutter—Riverpod for small updates, viewport checks to cut data, and a few tweaks for smoother scrolling. Binance’s stream was a fun testbed, and while it’s not the ultimate answer, it got the job done for me. If you’re tackling live lists in Flutter, maybe these bits can help—or spark a better idea. Let me know what you think!

Top comments (0)