widget_chat is live on pub.dev — drop-in AI chat for Flutter, FlutterFlow, React & Web. Start free →

← Back to Blog
Flutter AI Chat: Pin ListView to Bottom While Streaming

Flutter AI Chat: Pin ListView to Bottom While Streaming

flutterflutterflowstreamingscrollcontrollerchatbot

Flutter AI Chat: Pin ListView to Bottom While Streaming

The streaming chat looks perfect until the model gets wordy. A bubble grows a line, your auto-scroll fires, and the last line sits just below the fold. Then a user scrolls up to re-read something mid-answer and the list snaps back down on the next token, ripping history out from under them.

Both symptoms come from one root cause, and it is not the scroll call.

The version that almost works

final _scrollController = ScrollController();
String _streaming = '';

void _onToken(String token) {
  setState(() => _streaming += token);
  // Runs immediately — before the new text has been laid out.
  _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}

This is the code behind every flutter listview scroll maxScrollExtent not working bug report. It reads a number that has not been recomputed yet.

Why maxScrollExtent is always one frame stale

setState does not rebuild anything. It marks the element dirty and schedules a frame. The build and layout passes — and therefore the new height of your growing bubble — happen later in that frame. maxScrollExtent is produced by the viewport during layout, so at the moment you read it you get the previous frame's value: the extent from before the token you just appended.

At 30 tokens per second you are permanently one token behind. On a token that wraps a line, that's a whole line hidden under the fold — exactly the flutter chatbot autoscroll while typing response complaint.

animateTo makes it worse. Every token starts a new animation that cancels the one in flight, so the position never converges and the list visibly trails the text. For per-token updates you want jumpTo; save animateTo for the one-off moment the user sends a message.

Fix 1: scroll after layout, once per frame

WidgetsBinding.instance.addPostFrameCallback runs after the frame's layout phase, so maxScrollExtent is finally the real number. Coalesce with a flag — 40 tokens in a frame should not queue 40 callbacks.

Fix 2: a stick-to-bottom flag that the user can break

Pinning unconditionally is the second half of the bug. Track whether we are currently pinned, drop the pin the moment the user drags toward history, and re-attach only when they land back at the bottom themselves.

One API detail that trips people up: in a normal (non-reversed) list, ScrollDirection.forward means the offset is decreasing — the user is heading back up into older messages. ScrollDirection.reverse means they are heading down toward the newest.

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; // ScrollDirection

class ChatList extends StatefulWidget {
  const ChatList({
    super.key,
    required this.messages,
    required this.streamingText,
  });

  final List<String> messages;
  final String streamingText; // grows token by token

  @override
  State<ChatList> createState() => _ChatListState();
}

class _ChatListState extends State<ChatList> {
  final _controller = ScrollController();
  bool _stick = true;       // are we pinned to the bottom?
  bool _scheduled = false;  // one stick per frame, not one per token
  static const _slack = 56.0; // px that still counts as "at the bottom"

  bool get _atBottom {
    if (!_controller.hasClients) return true;
    final p = _controller.position;
    return p.maxScrollExtent - p.pixels <= _slack;
  }

  void _scheduleStick() {
    if (!_stick || _scheduled) return;
    _scheduled = true;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _scheduled = false;
      if (!_stick || !_controller.hasClients) return;
      final max = _controller.position.maxScrollExtent;
      if (_controller.offset >= max) return;
      _controller.jumpTo(max); // now reading a post-layout extent
    });
  }

  @override
  void didUpdateWidget(covariant ChatList old) {
    super.didUpdateWidget(old);
    final grew = widget.streamingText != old.streamingText ||
        widget.messages.length != old.messages.length;
    if (grew) _scheduleStick();
  }

  bool _onScroll(ScrollNotification n) {
    // forward == offset decreasing == user is reading history. Let go.
    if (n is UserScrollNotification &&
        n.direction == ScrollDirection.forward) {
      _stick = false;
    }
    // When the gesture settles, re-attach only if they landed at the bottom.
    if (n is ScrollEndNotification) _stick = _atBottom;
    return false;
  }

  @override
  Widget build(BuildContext context) {
    final items = [
      ...widget.messages,
      if (widget.streamingText.isNotEmpty) widget.streamingText,
    ];
    return NotificationListener<ScrollNotification>(
      onNotification: _onScroll,
      child: ListView.builder(
        controller: _controller,
        padding: const EdgeInsets.all(16),
        itemCount: items.length,
        itemBuilder: (_, i) => Bubble(text: items[i]),
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

Note that _stick is deliberately not in setState — it changes no pixels. If you want a "jump to latest" pill while the user is reading history, that one does need setState, and its tap handler should set _stick = true and animateTo(maxScrollExtent).

The reverse: true variant (less code, fewer edge cases)

With reverse: true, offset 0.0 is the bottom. A growing last bubble extends away from zero, so pixels never changes and the view stays pinned with no scroll call at all. The stale-extent problem simply stops existing.

ListView.builder(
  controller: _controller,
  reverse: true,
  padding: const EdgeInsets.all(16),
  itemCount: items.length,
  // index 0 is the newest message
  itemBuilder: (_, i) => Bubble(text: items[items.length - 1 - i]),
)

Three things flip when you reverse:

  • _atBottom becomes p.pixels <= _slack.
  • The release direction inverts: heading into history now increases the offset, so test for ScrollDirection.reverse.
  • _scheduleStick only needs jumpTo(0), and only after the pin was manually released.

The one visual caveat: when there are fewer messages than fill the viewport, a reversed list packs them against the bottom. That's usually right for chat, but it changes how an empty state or a header renders.

Feeding it a WidgetChat token stream

WidgetChat streams replies over Server-Sent Events from POST https://api.widgetchat.app/v1/chat/stream, token by token as data: lines. Any HTTP client works — no proprietary SDK required.

import 'dart:convert';
import 'package:http/http.dart' as http;

Stream<String> widgetChatTokens(String message) async* {
  final client = http.Client();
  final req = http.Request(
    'POST',
    Uri.parse('https://api.widgetchat.app/v1/chat/stream'),
  )
    ..headers.addAll({
      'Content-Type': 'application/json',
      'Accept': 'text/event-stream',
      // Use the exact auth header your WidgetChat project shows in the dashboard.
      'Authorization': 'Bearer $widgetChatKey',
    })
    ..body = jsonEncode({'message': message});

  final res = await client.send(req);
  try {
    await for (final line in res.stream
        .transform(utf8.decoder)
        .transform(const LineSplitter())) {
      if (!line.startsWith('data:')) continue; // skip event:/id:/keep-alives
      final data = line.substring(5).trim();
      if (data.isEmpty || data == '[DONE]') continue;
      yield data;
    }
  } finally {
    client.close();
  }
}

Wire it to the widget above with nothing more than a setState:

Future<void> _send(String text) async {
  setState(() {
    _messages.add(text);
    _streaming = '';
  });
  await for (final token in widgetChatTokens(text)) {
    setState(() => _streaming += token); // ChatList.didUpdateWidget does the rest
  }
  setState(() {
    _messages.add(_streaming);
    _streaming = '';
  });
}

The scroll logic is independent of your payload shape — if your data: lines are JSON, decode them and append the text field instead of the raw string.

The FlutterFlow version

FlutterFlow can consume the SSE endpoint natively: in the API call's Advanced Settings, enable Process Streaming Response, then use the onMessage action with Server Sent Event Data Text (or Data JSON plus a JSON path) to append each token into an App State string such as streamingReply.

App State changes rebuild your custom widget, which means didUpdateWidget is exactly the hook you need — the same _scheduleStick from above, unchanged:

// Custom Code > Custom Widgets > WidgetChatStreamList
// Parameters: width (double?), height (double?),
//             messages (List<String>), streamingText (String)
class WidgetChatStreamList extends StatefulWidget {
  const WidgetChatStreamList({
    super.key,
    this.width,
    this.height,
    required this.messages,
    required this.streamingText,
  });

  final double? width;
  final double? height;
  final List<String> messages;
  final String streamingText;

  @override
  State<WidgetChatStreamList> createState() => _WidgetChatStreamListState();
}

Give it the _controller, _stick, _scheduled, _atBottom, _scheduleStick, _onScroll and didUpdateWidget members verbatim, and wrap the returned ListView in a SizedBox(width: widget.width, height: widget.height, ...). Bind messages and streamingText to your App State variables in the widget's properties panel. That's the whole flutterflow chat scroll to bottom new message fix — no timers, no delayed futures.

Checklist for the remaining edge cases

  • Always guard with hasClients. A token can arrive after the route is popped; without the guard you get a thrown assertion.
  • Never call jumpTo during build. Post-frame only.
  • Keyboard insets change the extent too. Call _scheduleStick() when the input gains focus, or from didChangeMetrics on a WidgetsBindingObserver.
  • Keep _slack at two lines or so. Sub-pixel rounding at the exact bottom will otherwise unstick the pin at random.
  • Images and markdown that resize after layout need one more post-frame stick when they finish loading — the same helper handles it.

Try WidgetChat free

WidgetChat drops a real AI support chatbot into your Flutter or FlutterFlow app, streaming answers from your own content over the SSE endpoint above — and users can tap the mic to talk to it instead, with live captions and barge-in, in the same widget. Try WidgetChat free.

FlutterFlow's Process Streaming Response toggle and the SSE fields exposed to onMessage

UserScrollNotification is what tells you the user has taken over the scroll

WidgetChat — streaming AI support chat for Flutter and FlutterFlow apps

Author

About the author

Widget Chat is a team of developers and designers passionate about creating the best AI chatbot experience for Flutter, web, and mobile apps.

Comments

Comments are coming soon. We'd love to hear your thoughts!