dynamic_table_scroll 0.0.1 copy "dynamic_table_scroll: ^0.0.1" to clipboard
dynamic_table_scroll: ^0.0.1 copied to clipboard

A high-performance 2D Flutter table with sticky headers & pinned columns, 2D bidirectional scrolling, horizontal virtualization, and auto-fit column widths.

example/lib/main.dart

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),
                      ),
                    );
                  },
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
1
likes
160
points
--
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A high-performance 2D Flutter table with sticky headers & pinned columns, 2D bidirectional scrolling, horizontal virtualization, and auto-fit column widths.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter

More

Packages that depend on dynamic_table_scroll