dynamic_table_scroll 0.0.2
dynamic_table_scroll: ^0.0.2 copied to clipboard
A high-performance 2D Flutter table with sticky headers & pinned columns, 2D bidirectional scrolling, horizontal virtualization, and auto-fit column widths.
import 'package:dynamic_table_scroll/dynamic_table_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const DynamicTableExampleApp());
}
class ProductRecord {
final int id;
final String sku;
final String name;
final String category;
final double price;
final int stock;
final double rating;
final String supplier;
final String status;
const ProductRecord({
required this.id,
required this.sku,
required this.name,
required this.category,
required this.price,
required this.stock,
required this.rating,
required this.supplier,
required this.status,
});
}
class DynamicTableExampleApp extends StatelessWidget {
const DynamicTableExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Dynamic Table Scroll Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF3B82F6),
brightness: Brightness.light,
),
useMaterial3: true,
),
home: const TableDemoScreen(),
);
}
}
class TableDemoScreen extends StatefulWidget {
const TableDemoScreen({super.key});
@override
State<TableDemoScreen> createState() => _TableDemoScreenState();
}
class _TableDemoScreenState extends State<TableDemoScreen> {
final DynamicTableController _controller = DynamicTableController();
bool _isHeaderPinned = true;
int _pinnedColumnCount = 1;
String _searchQuery = '';
int _itemCount = 1000;
int _columnCount = 100; // 👈 100 columns by default
bool _isCompact = true; // 👈 Compact by default to easily fit more columns
bool _isAutoFit = true; // 👈 Dynamic Auto-Fit by default with min/max width
bool _isLandscape = false;
late List<ProductRecord> _allProducts;
@override
void initState() {
super.initState();
_generateProducts();
}
void _generateProducts() {
final categories = [
'Electronics',
'Furniture & Home Decor',
'Clothing & Apparel',
'Kitchenware',
'Sports & Outdoor Fitness Equipment',
];
final suppliers = [
'Apex Global International Trading Corp',
'Zenith Logistics',
'Vertex Supply',
'Nexus Goods & Co',
];
final statuses = ['In Stock', 'Low Stock', 'Out of Stock'];
_allProducts = List.generate(_itemCount, (i) {
final index = i + 1;
return ProductRecord(
id: index,
sku: 'SKU-${1000 + index}',
name: index % 5 == 0
? 'Super Premium Ergonomic Smart Device Model #$index with Extra Features'
: 'Premium Item #$index',
category: categories[index % categories.length],
price: (index * 13.5) % 350 + 19.99,
stock: (index * 7) % 120,
rating: 3.5 + (index % 15) / 10,
supplier: suppliers[index % suppliers.length],
status: statuses[index % statuses.length],
);
});
}
List<ProductRecord> get _filteredProducts {
if (_searchQuery.isEmpty) return _allProducts;
return _allProducts
.where(
(p) =>
p.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
p.sku.toLowerCase().contains(_searchQuery.toLowerCase()) ||
p.category.toLowerCase().contains(_searchQuery.toLowerCase()),
)
.toList();
}
void _toggleOrientation() {
setState(() {
_isLandscape = !_isLandscape;
});
if (_isLandscape) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
}
}
@override
void dispose() {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
super.dispose();
}
List<DynamicTableColumn<ProductRecord>> _buildColumns() {
final fontSize = _isCompact ? 11.0 : 14.0;
final badgeFontSize = _isCompact ? 10.0 : 12.0;
final baseColumns = [
DynamicTableColumn<ProductRecord>(
id: 'sku',
title: const Text('SKU Code'),
width: _isCompact ? 75.0 : 110.0,
minWidth: 65.0,
maxWidth: 130.0,
autoFit: _isAutoFit,
getTextValue: (p) => p.sku,
cellBuilder: (context, item, rowIndex) => Text(
item.sku,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w600,
color: const Color(0xFF2563EB),
),
),
),
DynamicTableColumn<ProductRecord>(
id: 'name',
title: const Text('Product Name'),
width: _isCompact ? 120.0 : 170.0,
minWidth: 100.0,
maxWidth: 260.0, // 👈 Max width limit for long product names
autoFit: _isAutoFit,
getTextValue: (p) => p.name,
cellBuilder: (context, item, rowIndex) => Text(
item.name,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: fontSize),
),
),
DynamicTableColumn<ProductRecord>(
id: 'category',
title: const Text('Category'),
width: _isCompact ? 85.0 : 130.0,
minWidth: 75.0,
maxWidth: 200.0, // 👈 Max width limit
autoFit: _isAutoFit,
getTextValue: (p) => p.category,
alignment: Alignment.centerLeft,
cellBuilder: (context, item, rowIndex) => Text(
item.category,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: fontSize),
),
),
DynamicTableColumn<ProductRecord>(
id: 'price',
title: const Text('Price (\$)'),
width: _isCompact ? 70.0 : 110.0,
minWidth: 60.0,
maxWidth: 120.0,
autoFit: _isAutoFit,
getTextValue: (p) => '\$${p.price.toStringAsFixed(2)}',
alignment: Alignment.centerRight,
cellBuilder: (context, item, rowIndex) => Text(
'\$${item.price.toStringAsFixed(2)}',
style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600),
),
),
DynamicTableColumn<ProductRecord>(
id: 'stock',
title: const Text('Stock Qty'),
width: _isCompact ? 65.0 : 100.0,
minWidth: 55.0,
maxWidth: 110.0,
autoFit: _isAutoFit,
getTextValue: (p) => '${p.stock}',
alignment: Alignment.centerRight,
cellBuilder: (context, item, rowIndex) => Text(
'${item.stock}',
style: TextStyle(fontSize: fontSize),
),
),
DynamicTableColumn<ProductRecord>(
id: 'status',
title: const Text('Status'),
width: _isCompact ? 85.0 : 130.0,
minWidth: 75.0,
maxWidth: 140.0,
autoFit: _isAutoFit,
getTextValue: (p) => p.status,
alignment: Alignment.center,
cellBuilder: (context, item, rowIndex) {
final isLow = item.status == 'Low Stock';
final isOut = item.status == 'Out of Stock';
final color = isOut
? const Color(0xFFEF4444)
: isLow
? const Color(0xFFF59E0B)
: const Color(0xFF10B981);
return Container(
padding: EdgeInsets.symmetric(
horizontal: _isCompact ? 5 : 8,
vertical: _isCompact ? 2 : 4,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Text(
item.status,
style: TextStyle(
fontSize: badgeFontSize,
fontWeight: FontWeight.w600,
color: color,
),
),
);
},
),
DynamicTableColumn<ProductRecord>(
id: 'rating',
title: const Text('Rating'),
width: _isCompact ? 65.0 : 100.0,
minWidth: 60.0,
maxWidth: 110.0,
autoFit: _isAutoFit,
getTextValue: (p) => p.rating.toStringAsFixed(1),
alignment: Alignment.center,
cellBuilder: (context, item, rowIndex) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star, size: _isCompact ? 12 : 16, color: const Color(0xFFF59E0B)),
const SizedBox(width: 2),
Text(
item.rating.toStringAsFixed(1),
style: TextStyle(fontSize: fontSize),
),
],
),
),
DynamicTableColumn<ProductRecord>(
id: 'supplier',
title: const Text('Supplier'),
width: _isCompact ? 110.0 : 160.0,
minWidth: 80.0,
maxWidth: 240.0, // 👈 Max width limit for long supplier names
autoFit: _isAutoFit,
getTextValue: (p) => p.supplier,
cellBuilder: (context, item, rowIndex) => Text(
item.supplier,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: fontSize),
),
),
];
if (_columnCount <= baseColumns.length) {
return baseColumns.sublist(0, _columnCount);
}
final allCols = List<DynamicTableColumn<ProductRecord>>.from(baseColumns);
// Generate up to _columnCount columns
final metricNames = [
'Cost',
'Margin %',
'Warehouse',
'Location',
'Weight (kg)',
'Height (cm)',
'Width (cm)',
'Depth (cm)',
'Tax Rate',
'Discount %',
'Min Order',
'Max Order',
'Reorder Pt',
'Lead Time (d)',
'Safety Stock',
'EOQ',
'Handling Fee',
'Shipping Class',
'Barcode',
'Bin Location',
'Aisle',
'Shelf',
'Batch #',
'Expiry Date',
'Mfg Date',
'Warranty (mo)',
'Return Rate %',
'Defect Rate %',
'Customer Score',
'Review Count',
];
for (int i = baseColumns.length; i < _columnCount; i++) {
final metricIndex = (i - baseColumns.length) % metricNames.length;
final metricName = '${metricNames[metricIndex]} #${i + 1}';
final colId = 'col_$i';
allCols.add(
DynamicTableColumn<ProductRecord>(
id: colId,
title: Text(metricName),
width: _isCompact ? 80.0 : 120.0,
minWidth: 60.0,
maxWidth: 160.0,
autoFit: _isAutoFit,
getTextValue: (item) => '${((item.id * (i + 7) * 17) % 997) + 1}',
alignment: Alignment.center,
cellBuilder: (context, item, rowIndex) {
final val = ((item.id * (i + 7) * 17) % 997) + 1;
return Text(
'$val',
style: TextStyle(fontSize: fontSize),
);
},
),
);
}
return allCols;
}
@override
Widget build(BuildContext context) {
final columns = _buildColumns();
final tableStyle = _isCompact
? const DynamicTableStyle.compact()
: const DynamicTableStyle();
return Scaffold(
appBar: AppBar(
title: const Text('Dynamic Table Scroll Demo'),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF0F172A),
actions: [
IconButton(
tooltip: _isLandscape ? 'Portrait Mode' : 'Landscape Mode (Xoay ngang)',
icon: Icon(_isLandscape ? Icons.screen_lock_portrait : Icons.screen_rotation),
onPressed: _toggleOrientation,
),
],
),
body: Container(
color: const Color(0xFFF8FAFC),
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
// Controls Bar
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: 170,
height: 36,
child: TextField(
decoration: InputDecoration(
hintText: 'Search items...',
prefixIcon: const Icon(Icons.search, size: 16),
contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
onChanged: (val) => setState(() => _searchQuery = val),
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Cols: '),
DropdownButton<int>(
value: _columnCount,
items: const [
DropdownMenuItem(value: 8, child: Text('8 cols')),
DropdownMenuItem(value: 20, child: Text('20 cols')),
DropdownMenuItem(value: 50, child: Text('50 cols')),
DropdownMenuItem(value: 100, child: Text('100 cols (Default)')),
DropdownMenuItem(value: 200, child: Text('200 cols')),
],
onChanged: (val) {
if (val != null) {
setState(() => _columnCount = val);
}
},
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Rows: '),
DropdownButton<int>(
value: _itemCount,
items: const [
DropdownMenuItem(value: 100, child: Text('100')),
DropdownMenuItem(value: 1000, child: Text('1,000')),
DropdownMenuItem(value: 10000, child: Text('10,000')),
],
onChanged: (val) {
if (val != null) {
setState(() {
_itemCount = val;
_generateProducts();
});
}
},
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Auto-Fit: '),
Switch(
value: _isAutoFit,
onChanged: (val) => setState(() => _isAutoFit = val),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Compact: '),
Switch(
value: _isCompact,
onChanged: (val) => setState(() => _isCompact = val),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Sticky Header: '),
Switch(
value: _isHeaderPinned,
onChanged: (val) => setState(() => _isHeaderPinned = val),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Pinned Cols: '),
DropdownButton<int>(
value: _pinnedColumnCount,
items: const [
DropdownMenuItem(value: 0, child: Text('0')),
DropdownMenuItem(value: 1, child: Text('1')),
DropdownMenuItem(value: 2, child: Text('2')),
DropdownMenuItem(value: 3, child: Text('3')),
],
onChanged: (val) {
if (val != null) {
setState(() => _pinnedColumnCount = val);
}
},
),
],
),
],
),
const SizedBox(height: 8),
// Table Container
Expanded(
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE2E8F0)),
),
clipBehavior: Clip.antiAlias,
child: DynamicTableScrollView<ProductRecord>(
controller: _controller,
columns: columns,
rows: _filteredProducts,
isHeaderPinned: _isHeaderPinned,
pinnedColumnCount: _pinnedColumnCount,
style: tableStyle,
onRowTap: (product, index) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Tapped: ${product.name} (${product.sku})',
),
duration: const Duration(seconds: 1),
),
);
},
),
),
),
],
),
),
);
}
}