json_view_plus 1.0.0
json_view_plus: ^1.0.0 copied to clipboard
High-performance Flutter JSON tree viewer with lazy row rendering, syntax highlighting, expansion controls, and Chrome DevTools-style large JSON browsing.
example/lib/main.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:json_view_plus/json_view_plus.dart';
import 'json_data.dart';
void main() => runApp(const MyApp());
class _DemoTheme {
static ThemeData light() => _theme(Brightness.light);
static ThemeData dark() => _theme(Brightness.dark);
static ThemeData _theme(Brightness brightness) {
final primary = _DemoTokens.primaryColor(brightness);
return ThemeData(
useMaterial3: true,
brightness: brightness,
fontFamily: _DemoTokens.fontFamilyBase,
colorScheme: ColorScheme.fromSeed(
seedColor: primary,
brightness: brightness,
primary: primary,
error: _DemoTokens.errorColor(brightness),
surface: _DemoTokens.primaryBackground(brightness),
outline: _DemoTokens.primaryBorder(brightness),
),
scaffoldBackgroundColor: _DemoTokens.secondaryBackground(brightness),
appBarTheme: AppBarTheme(
backgroundColor: _DemoTokens.primaryBackground(brightness),
foregroundColor: _DemoTokens.primaryText(brightness),
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
fontSize: _DemoTokens.fontSizeMD,
fontWeight: _DemoTokens.fontWeightSemibold,
color: _DemoTokens.primaryText(brightness),
),
),
cardTheme: CardThemeData(
color: _DemoTokens.primaryBackground(brightness),
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_DemoTokens.radiusXLarge),
side: BorderSide(color: _DemoTokens.primaryBorder(brightness)),
),
),
textTheme: _textTheme(brightness),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: _DemoTokens.primaryBackground(brightness),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(_DemoTokens.radiusXLarge),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
hintStyle: TextStyle(
fontSize: _DemoTokens.fontSizeSM,
color: _DemoTokens.tertiaryText(brightness),
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: _DemoTokens.secondaryText(brightness),
),
),
dividerTheme: DividerThemeData(
color: _DemoTokens.primaryBorder(brightness),
thickness: 1,
space: 1,
),
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: brightness == Brightness.light
? _DemoTokens.gray800
: _DemoTokens.gray700,
borderRadius: BorderRadius.circular(_DemoTokens.radiusLarge),
),
textStyle: const TextStyle(
fontSize: _DemoTokens.fontSizeXS,
color: Colors.white,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
);
}
static TextTheme _textTheme(Brightness brightness) {
final primary = _DemoTokens.primaryText(brightness);
return TextTheme(
displayLarge: TextStyle(
fontSize: _DemoTokens.fontSize3XL,
fontWeight: _DemoTokens.fontWeightSemibold,
color: primary,
),
displayMedium: TextStyle(
fontSize: _DemoTokens.fontSize2XL,
fontWeight: _DemoTokens.fontWeightSemibold,
color: primary,
),
titleLarge: TextStyle(
fontSize: _DemoTokens.fontSizeLG,
fontWeight: _DemoTokens.fontWeightSemibold,
color: primary,
),
titleMedium: TextStyle(
fontSize: _DemoTokens.fontSizeMD,
fontWeight: _DemoTokens.fontWeightSemibold,
color: primary,
),
bodyLarge: TextStyle(fontSize: _DemoTokens.fontSizeMD, color: primary),
bodyMedium: TextStyle(fontSize: _DemoTokens.fontSizeSM, color: primary),
bodySmall: TextStyle(fontSize: _DemoTokens.fontSizeXS, color: primary),
labelLarge: TextStyle(
fontSize: _DemoTokens.fontSizeSM,
fontWeight: _DemoTokens.fontWeightSemibold,
color: primary,
),
labelMedium: TextStyle(
fontSize: _DemoTokens.fontSizeXS,
fontWeight: _DemoTokens.fontWeightSemibold,
color: primary,
),
labelSmall: TextStyle(
fontSize: _DemoTokens.fontSizeXS,
fontWeight: _DemoTokens.fontWeightSemibold,
color: primary,
),
);
}
}
class _DemoTokens {
static const lightPrimaryBackground = Color(0xFFFFFFFF);
static const lightSecondaryBackground = Color(0xFFF9FAFB);
static const lightPrimaryText = Color(0xFF111827);
static const lightSecondaryText = Color(0xFF4B5563);
static const lightTertiaryText = Color(0xFF9CA3AF);
static const lightPrimaryBorder = Color(0xFFE5E7EB);
static const darkPrimaryBackground = Color(0xFF141414);
static const darkSecondaryBackground = Color(0xFF1C1C1C);
static const darkPrimaryText = Color(0xFFFFFFFF);
static const darkSecondaryText = Color(0x99FFFFFF);
static const darkTertiaryText = Color(0x66FFFFFF);
static const darkPrimaryBorder = Color(0xFF383838);
static const primary = Color(0xFF2563EB);
static const primaryDark = Color(0xFF3B82F6);
static const error = Color(0xFFDC2626);
static const errorDark = Color(0xFFEF4444);
static const gray700 = Color(0xFF374151);
static const gray800 = Color(0xFF1F2937);
static const radiusLarge = 8.0;
static const radiusXLarge = 12.0;
static const String? fontFamilyBase = null;
static const fontSize3XL = 30.0;
static const fontSize2XL = 24.0;
static const fontSizeLG = 18.0;
static const fontSizeMD = 16.0;
static const fontSizeSM = 14.0;
static const fontSizeXS = 12.0;
static const fontWeightSemibold = FontWeight.w600;
static Color primaryColor(Brightness brightness) =>
brightness == Brightness.light ? primary : primaryDark;
static Color errorColor(Brightness brightness) =>
brightness == Brightness.light ? error : errorDark;
static Color primaryBackground(Brightness brightness) =>
brightness == Brightness.light
? lightPrimaryBackground
: darkPrimaryBackground;
static Color secondaryBackground(Brightness brightness) =>
brightness == Brightness.light
? lightSecondaryBackground
: darkSecondaryBackground;
static Color primaryBorder(Brightness brightness) =>
brightness == Brightness.light ? lightPrimaryBorder : darkPrimaryBorder;
static Color primaryText(Brightness brightness) =>
brightness == Brightness.light ? lightPrimaryText : darkPrimaryText;
static Color secondaryText(Brightness brightness) =>
brightness == Brightness.light ? lightSecondaryText : darkSecondaryText;
static Color tertiaryText(Brightness brightness) =>
brightness == Brightness.light ? lightTertiaryText : darkTertiaryText;
}
class _DemoTab {
const _DemoTab({required this.label, required this.subtitle});
final String label;
final String subtitle;
}
class _DemoTabs extends StatelessWidget {
const _DemoTabs({
required this.tabs,
required this.selectedIndex,
required this.onChanged,
});
final List<_DemoTab> tabs;
final int selectedIndex;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
return DecoratedBox(
decoration: BoxDecoration(
color: _DemoTokens.primaryBackground(brightness),
border: Border(
bottom: BorderSide(color: _DemoTokens.primaryBorder(brightness)),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 6),
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < tabs.length; index++) ...[
if (index > 0) const SizedBox(width: 4),
_DemoTabButton(
tab: tabs[index],
selected: index == selectedIndex,
onTap: () => onChanged(index),
),
],
],
),
),
);
}
}
class _DemoTabButton extends StatefulWidget {
const _DemoTabButton({
required this.tab,
required this.selected,
required this.onTap,
});
final _DemoTab tab;
final bool selected;
final VoidCallback onTap;
@override
State<_DemoTabButton> createState() => _DemoTabButtonState();
}
class _DemoTabButtonState extends State<_DemoTabButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
final hoverBg = brightness == Brightness.dark
? const Color(0x14FFFFFF)
: const Color(0x0A000000);
final color = widget.selected
? _DemoTokens.primaryText(brightness)
: _DemoTokens.secondaryText(brightness);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
child: Tooltip(
message: widget.tab.subtitle,
waitDuration: const Duration(milliseconds: 400),
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
padding: const EdgeInsets.fromLTRB(12, 3, 12, 5),
decoration: BoxDecoration(
color: widget.selected
? _DemoTokens.primaryBackground(brightness)
: (_hovered ? hoverBg : Colors.transparent),
borderRadius: BorderRadius.circular(7),
border: widget.selected
? Border.all(color: _DemoTokens.primaryBorder(brightness))
: null,
boxShadow: widget.selected
? const [
BoxShadow(
color: Color(0x0F000000),
offset: Offset(0, 2),
blurRadius: 4,
spreadRadius: -1,
),
BoxShadow(
color: Color(0x0A000000),
offset: Offset(0, 1),
blurRadius: 2,
spreadRadius: -1,
),
]
: null,
),
child: Text(
widget.tab.label,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: widget.selected ? FontWeight.w600 : FontWeight.w500,
letterSpacing: -0.1,
height: 1.0,
),
),
),
),
),
);
}
}
class _DemoPanel extends StatelessWidget {
const _DemoPanel({
required this.title,
required this.subtitle,
required this.icon,
required this.child,
this.trailing,
});
final String title;
final String subtitle;
final IconData icon;
final Widget child;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
return DecoratedBox(
decoration: BoxDecoration(
color: _DemoTokens.primaryBackground(brightness),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 37,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _DemoTokens.primaryBackground(brightness),
border: Border(
bottom: BorderSide(
color: _DemoTokens.primaryBorder(brightness),
),
),
),
child: Row(
children: [
Tooltip(
message: subtitle,
child: Icon(
icon,
size: 14,
color: _DemoTokens.tertiaryText(brightness),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _DemoTokens.primaryText(brightness),
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.0,
),
),
),
if (trailing != null) ...[
const SizedBox(width: 8),
trailing!,
],
],
),
),
Expanded(child: child),
],
),
),
);
}
}
class _DemoIconButton extends StatefulWidget {
const _DemoIconButton({
required this.icon,
required this.onPressed,
this.tooltip,
this.iconColor,
this.hoverIconColor,
this.size = 28,
this.iconSize = 14,
});
final IconData icon;
final VoidCallback onPressed;
final String? tooltip;
final Color? iconColor;
final Color? hoverIconColor;
final double size;
final double iconSize;
@override
State<_DemoIconButton> createState() => _DemoIconButtonState();
}
class _DemoIconButtonState extends State<_DemoIconButton> {
bool _hovered = false;
bool _pressed = false;
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
final hoverBg = brightness == Brightness.dark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.04);
final iconColor = _hovered && widget.hoverIconColor != null
? widget.hoverIconColor!
: widget.iconColor ?? _DemoTokens.secondaryText(brightness);
Widget button = MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: _hovered && !_pressed ? hoverBg : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(widget.icon, size: widget.iconSize, color: iconColor),
),
),
);
if (widget.tooltip != null) {
button = Tooltip(message: widget.tooltip!, child: button);
}
return button;
}
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
ThemeMode _themeMode = ThemeMode.light;
void _toggleTheme() {
setState(() {
_themeMode =
_themeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'JsonView Plus',
debugShowCheckedModeBanner: false,
theme: _DemoTheme.light(),
darkTheme: _DemoTheme.dark(),
themeMode: _themeMode,
home: HomePage(
themeMode: _themeMode,
onToggleTheme: _toggleTheme,
),
);
}
}
class _Sample {
const _Sample({
required this.label,
required this.subtitle,
required this.build,
});
final String label;
final String subtitle;
final Widget Function(BuildContext, Brightness, JsonViewController) build;
}
class HomePage extends StatefulWidget {
const HomePage({
super.key,
required this.themeMode,
required this.onToggleTheme,
});
final ThemeMode themeMode;
final VoidCallback onToggleTheme;
@override
State<HomePage> createState() => _HomePageState();
}
const double _mobilePaneBreakpoint = 600;
const double _normalPaneWidth = 420;
const double _widePaneWidth = _normalPaneWidth * 1.5;
const double _paneGap = 16;
const EdgeInsets _paneCanvasPadding = EdgeInsets.all(16);
const EdgeInsets _panelBodyPadding = EdgeInsets.all(12);
const int _sampleCount = 5;
class _HomePageState extends State<HomePage> {
int _selected = 0;
final ScrollController _paneScrollController = ScrollController();
final PageController _mobilePageController = PageController();
final List<JsonViewController> _jsonControllers = List.generate(
_sampleCount,
(_) => JsonViewController(),
);
final List<bool> _expandedStates = List<bool>.filled(_sampleCount, true);
List<_Sample> get _samples => [
_Sample(
label: 'Map',
subtitle: 'Nested object, first layer open',
build: (_, __, c) => JsonView(
json: getJsonData(),
padding: _panelBodyPadding,
jsonViewController: c,
styleScheme: const JsonStyleScheme(
openAtStart: true,
arrow: Icon(LucideIcons.chevron_right, size: 12),
),
),
),
_Sample(
label: 'Large list',
subtitle: 'Chunked expansion, 200+ items',
build: (_, __, c) => JsonView(
json: largeJsonData(),
padding: _panelBodyPadding,
jsonViewController: c,
styleScheme: const JsonStyleScheme(
openAtStart: true,
arrow: Icon(LucideIcons.chevron_right, size: 12),
),
),
),
_Sample(
label: 'List',
subtitle: 'Plain array root',
build: (_, __, c) => JsonView(
json: listJsonData(),
padding: _panelBodyPadding,
jsonViewController: c,
styleScheme: const JsonStyleScheme(
arrow: Icon(LucideIcons.chevron_right, size: 12),
),
),
),
_Sample(
label: 'Raw string',
subtitle: 'Decoded via isolate when large',
build: (_, __, c) => JsonView.fromJsonString(
jsonString: jsonEncode(largeJsonData()),
decodeStrategy: JsonDecodeStrategy.auto,
padding: _panelBodyPadding,
jsonViewController: c,
styleScheme: const JsonStyleScheme(
arrow: Icon(LucideIcons.chevron_right, size: 12),
charactersBeforeCutoff: 100,
),
),
),
_Sample(
label: 'Compact',
subtitle: 'Nested JSON-in-string, summaries preferred',
build: (_, __, c) => JsonView(
json: {
'metadata': '{"id":42,"tags":["json","viewer"],"active":true}',
'payload': largeJsonData(),
'notes': 'Compact mode keeps big branches shallow by default.',
},
padding: _panelBodyPadding,
jsonViewController: c,
styleScheme: const JsonStyleScheme(
arrow: Icon(LucideIcons.chevron_right, size: 12),
compactMode: true,
compactDepth: 0,
parseNestedJsonStrings: true,
charactersBeforeCutoff: 80,
),
),
),
];
@override
void dispose() {
_paneScrollController.dispose();
_mobilePageController.dispose();
for (final controller in _jsonControllers) {
controller.dispose();
}
super.dispose();
}
double _paneOffsetFor(int index) {
var offset = 0.0;
for (var i = 0; i < index; i++) {
offset += _paneWidthFor(i) + _paneGap;
}
return offset;
}
double _paneWidthFor(int index) =>
index == 3 ? _widePaneWidth : _normalPaneWidth;
void _selectSample(int index) {
setState(() => _selected = index);
if (_mobilePageController.hasClients) {
_mobilePageController.animateToPage(
index,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
}
if (!_paneScrollController.hasClients) return;
final maxExtent = _paneScrollController.position.maxScrollExtent;
final target = _paneOffsetFor(index).clamp(0.0, maxExtent);
_paneScrollController.animateTo(
target,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
}
void _handleMobilePageChanged(int index) {
if (_selected == index) return;
setState(() => _selected = index);
}
void _expandPane(int index) {
setState(() => _expandedStates[index] = true);
_jsonControllers[index].expandAll();
}
void _collapsePane(int index) {
setState(() => _expandedStates[index] = false);
_jsonControllers[index].collapseAll();
}
Widget _buildPane(int index, Brightness brightness, bool isDark) {
final sample = _samples[index];
return _DemoPanel(
title: sample.label,
subtitle: sample.subtitle,
icon: LucideIcons.braces,
trailing: _DemoIconButton(
icon: _expandedStates[index]
? LucideIcons.chevrons_down_up
: LucideIcons.chevrons_up_down,
tooltip: _expandedStates[index] ? 'Collapse all' : 'Expand all',
size: 24,
iconSize: 13,
iconColor: _DemoTokens.secondaryText(brightness),
hoverIconColor: _DemoTokens.primaryColor(brightness),
onPressed: () =>
_expandedStates[index] ? _collapsePane(index) : _expandPane(index),
),
child: JsonConfig(
data: JsonConfigData(
gap: 100,
style: JsonStyleScheme(
openAtStart: false,
arrow: const Icon(LucideIcons.chevron_right, size: 12),
depth: 2,
quotation: JsonQuotation.doubleQuote,
),
color: isDark ? defaultDarkColorScheme : defaultLightColorScheme,
),
child: sample.build(context, brightness, _jsonControllers[index]),
),
);
}
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
final isDark = brightness == Brightness.dark;
return Scaffold(
backgroundColor: _DemoTokens.secondaryBackground(brightness),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_AppHeader(
themeMode: widget.themeMode,
onToggleTheme: widget.onToggleTheme,
),
_DemoTabs(
tabs: [
for (final s in _samples)
_DemoTab(label: s.label, subtitle: s.subtitle),
],
selectedIndex: _selected,
onChanged: _selectSample,
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < _mobilePaneBreakpoint) {
return PageView.builder(
controller: _mobilePageController,
onPageChanged: _handleMobilePageChanged,
itemCount: _samples.length,
itemBuilder: (context, index) {
return Padding(
padding: _paneCanvasPadding,
child: _buildPane(index, brightness, isDark),
);
},
);
}
return Scrollbar(
controller: _paneScrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _paneScrollController,
scrollDirection: Axis.horizontal,
primary: false,
padding: _paneCanvasPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < _samples.length; i++) ...[
SizedBox(
width: _paneWidthFor(i),
height: double.infinity,
child: _buildPane(i, brightness, isDark),
),
if (i != _samples.length - 1)
const SizedBox(width: _paneGap),
],
],
),
),
);
},
),
),
],
),
),
);
}
}
/// Flat 46px app-header — bottom border only, brand chip + title + theme toggle.
class _AppHeader extends StatelessWidget {
const _AppHeader({
required this.themeMode,
required this.onToggleTheme,
});
final ThemeMode themeMode;
final VoidCallback onToggleTheme;
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
final primary = _DemoTokens.primaryColor(brightness);
return Container(
height: 46,
decoration: BoxDecoration(
color: _DemoTokens.primaryBackground(brightness),
border: Border(
bottom: BorderSide(
color: _DemoTokens.primaryBorder(brightness),
width: 1,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: Icon(LucideIcons.braces, size: 14, color: primary),
),
const SizedBox(width: 10),
Text(
'JsonView Plus',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.0,
color: _DemoTokens.primaryText(brightness),
),
),
const Spacer(),
_DemoIconButton(
icon: themeMode == ThemeMode.dark
? LucideIcons.sun
: LucideIcons.moon,
tooltip: themeMode == ThemeMode.dark
? 'Switch to light'
: 'Switch to dark',
iconColor: _DemoTokens.primaryText(brightness),
onPressed: onToggleTheme,
),
],
),
);
}
}