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.3
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 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() {
_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.
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.
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: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: AnimatedStreamingMarkdown(
blocks: _blocks,
asSliver: true,
enableSelection: true,
tokenStaggerDelay: const Duration(milliseconds: 45),
tokenAnimationDuration: const Duration(milliseconds: 180),
tokenAnimationCurve: Curves.easeOutCubic,
placeholder: const Center(
child: Text('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.
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. - Dispose the parser from
dispose. - Keep token delays short for high-volume chat streams.