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
appendonly when each update contains the next new text chunk. - Use
replacewhen each update contains the whole current document. - Enable
enableSelectionwhen users need copy behavior. - Use
AnimatedStreamingMarkdownSelectionAreafor selectable slivers. - Dispose app-owned parser and selection controllers from
dispose. - Keep token delays short for high-volume chat streams.