Skip to main content

Build a Streaming Markdown Demo

This guide builds a small Flutter screen that simulates a streaming Markdown answer. It covers the pieces most apps need:

  • starting and disposing a parser
  • appending streamed chunks
  • rendering parsed blocks
  • animating token reveal
  • enabling Markdown-aware selection copy
  • resetting the stream

1. Create a Flutter App

Add the package:

dependencies:
animated_streaming_markdown: ^0.3.7

Install dependencies:

flutter pub get

Import the package:

import 'package:animated_streaming_markdown/animated_streaming_markdown.dart';

2. Add a Demo Screen

Create a StreamingMarkdownDemo widget:

import 'dart:async';

import 'package:animated_streaming_markdown/animated_streaming_markdown.dart';
import 'package:flutter/material.dart';

class StreamingMarkdownDemo extends StatefulWidget {
const StreamingMarkdownDemo({super.key});


State<StreamingMarkdownDemo> createState() => _StreamingMarkdownDemoState();
}

class _StreamingMarkdownDemoState extends State<StreamingMarkdownDemo> {
final MarkdownStreamParser _parser = MarkdownStreamParser();
final AnimatedMarkdownSelectionController _selectionController =
AnimatedMarkdownSelectionController();
final List<MarkdownBlock> _blocks = <MarkdownBlock>[];

bool _parserReady = false;
bool _streaming = false;


void initState() {
super.initState();
_startParser();
}

Future<void> _startParser() async {
await _parser.start();
if (!mounted) return;
setState(() {
_parserReady = true;
});
}


void dispose() {
_selectionController.dispose();
_parser.dispose();
super.dispose();
}
}

Use one parser per active stream. Starting the parser in initState keeps the UI code simple, and disposing it prevents the isolate from outliving the screen. Keep the parser and selection controller in State, not in build. For a chat list, give each message a stable key and keep the message renderer alive while it is offscreen; this preserves settled animation, selection, and controller state when a new response is appended.

3. Simulate a Streaming Answer

Add sample chunks and a streaming method inside _StreamingMarkdownDemoState:

static const List<String> _chunks = <String>[
'# Streaming Markdown Demo\n\n',
'This message arrives in **small chunks** and is parsed as it grows.\n\n',
'## Features\n\n',
'- Incremental append parsing\n',
'- Token reveal animation\n',
'- Markdown-aware selection copy\n\n',
'```dart\n',
'final parser = MarkdownStreamParser();\n',
'await parser.start();\n',
'final result = await parser.append(chunk);\n',
'```\n\n',
'| API | Purpose |\n',
'| --- | --- |\n',
'| `replace` | Parse a complete snapshot |\n',
'| `append` | Parse the next streamed chunk |\n',
];

Future<void> _runDemoStream() async {
if (!_parserReady || _streaming) return;

setState(() {
_streaming = true;
_blocks.clear();
});

await _parser.replace('');

for (final chunk in _chunks) {
await Future<void>.delayed(const Duration(milliseconds: 220));
final result = await _parser.append(chunk);
if (!mounted) return;

setState(() {
_blocks
..clear()
..addAll(result.blocks);
});
}

if (!mounted) return;
setState(() {
_streaming = false;
});
}

Future<void> _resetDemo() async {
if (!_parserReady) return;

final result = await _parser.replace('');
if (!mounted) return;

setState(() {
_blocks
..clear()
..addAll(result.blocks);
_streaming = false;
});
}

Use replace('') before a new stream so the parser state starts from an empty document. During a real API call, call append(chunk) whenever your backend or model emits the next text chunk.

The selection engine uses the same source snapshot while chunks arrive. Users can select formatted text, tables, code, images, LaTeX, and HTML blocks; the highlight remains one flat visual selection and edge dragging scrolls like a TextField. Rich copy is available on Web and all native targets, with plain text fallback when the clipboard host cannot accept HTML.

For a complete real-world chatbot example, see the static Live Chat Demo. The example app supports local Ollama and cloud providers that stream Markdown responses.

4. Render the Blocks

Add the build method:


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Streaming Markdown'),
actions: [
IconButton(
tooltip: 'Reset',
onPressed: _parserReady && !_streaming ? _resetDemo : null,
icon: const Icon(Icons.refresh),
),
],
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: AnimatedStreamingMarkdownSelectionArea(
controller: _selectionController,
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: AnimatedStreamingMarkdown(
blocks: _blocks,
asSliver: true,
enableSelection: true,
selectionController: _selectionController,
tokenStaggerDelay: const Duration(milliseconds: 45),
tokenAnimationDuration:
const Duration(milliseconds: 180),
tokenAnimationCurve: Curves.easeOutCubic,
placeholder: 'Tap Start to stream Markdown.',
tokenAnimationBuilder: (context, token) {
final t = Curves.easeOutCubic.transform(token.value);
return Opacity(
opacity: t,
child: Transform.translate(
offset: Offset(0, (1 - t) * 6),
child: token.child,
),
);
},
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _parserReady && !_streaming ? _runDemoStream : null,
icon: _streaming
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_arrow),
label: Text(_streaming ? 'Streaming' : 'Start Demo'),
),
),
],
),
),
);
}

AnimatedStreamingMarkdown can render as a normal box widget or as a sliver. For chat and document screens, asSliver: true fits naturally inside CustomScrollView. When selection is enabled, the explicit selection area keeps source ranges stable while sliver children mount and unmount.

5. Run It

Use the screen from main.dart:

import 'package:flutter/material.dart';

import 'streaming_markdown_demo.dart';

void main() {
runApp(const DemoApp());
}

class DemoApp extends StatelessWidget {
const DemoApp({super.key});


Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const StreamingMarkdownDemo(),
);
}
}

Run the app:

flutter run

Adapting to a Real Stream

Replace _chunks and _runDemoStream with your own stream source:

Future<void> readModelStream(Stream<String> stream) async {
await _parser.replace('');

await for (final chunk in stream) {
final result = await _parser.append(chunk);
if (!mounted) return;
setState(() {
_blocks
..clear()
..addAll(result.blocks);
});
}
}

If your backend sends complete snapshots instead of appended chunks, call replace(markdown) for each update.

Practical Notes

  • Keep a parser alive for the lifetime of one active stream.
  • Use append only when each update contains the next new text chunk.
  • Use replace when each update contains the whole current document.
  • Enable enableSelection when users need copy behavior.
  • Use AnimatedStreamingMarkdownSelectionArea for selectable slivers.
  • Dispose app-owned parser and selection controllers from dispose.
  • Keep token delays short for high-volume chat streams.