image

13 Apr 2026

9K

35K

Building a Product Grid Widget with Filter, Sort, and Infinite Scroll in Flutter

Creating a robust e-commerce or catalog application in Flutter often requires displaying a list of products in a grid format, complete with features like filtering, sorting, and infinite scrolling. This article provides a professional guide to implementing such a versatile product grid widget.

1. Product Model and API Service

First, define a data model for your product and a service to simulate or fetch data from an API. For simplicity, we'll use a mock API service.

Product Model


class Product {
  final String id;
  final String name;
  final double price;
  final String category;
  final String imageUrl;

  Product({
    required this.id,
    required this.name,
    required this.price,
    required this.category,
    required this.imageUrl,
  });

  factory Product.fromJson(Map<String, dynamic> json) {
    return Product(
      id: json['id'],
      name: json['name'],
      price: json['price'].toDouble(),
      category: json['category'],
      imageUrl: json['imageUrl'],
    );
  }
}

Mock API Service

This service simulates fetching paginated product data with optional filters and sorting.


enum SortOrder {
  none,
  priceAscending,
  priceDescending,
}

class ProductApiService {
  final List<Product> _allProducts = List.generate(
    100,
    (index) => Product(
      id: 'prod_${index + 1}',
      name: 'Product ${index + 1}',
      price: 10.0 + (index * 0.5),
      category: index % 3 == 0
          ? 'Electronics'
          : index % 3 == 1
              ? 'Books'
              : 'Clothing',
      imageUrl: 'https://via.placeholder.com/150/0000FF/FFFFFF?text=Product+${index + 1}',
    ),
  );

  Future<List<Product>> fetchProducts({
    int page = 1,
    int pageSize = 10,
    String? categoryFilter,
    SortOrder sortOrder = SortOrder.none,
  }) async {
    await Future.delayed(const Duration(milliseconds: 700)); // Simulate network delay

    List<Product> filteredProducts = List.from(_allProducts);

    // Apply category filter
    if (categoryFilter != null && categoryFilter.isNotEmpty) {
      filteredProducts = filteredProducts
          .where((product) => product.category == categoryFilter)
          .toList();
    }

    // Apply sorting
    if (sortOrder == SortOrder.priceAscending) {
      filteredProducts.sort((a, b) => a.price.compareTo(b.price));
    } else if (sortOrder == SortOrder.priceDescending) {
      filteredProducts.sort((a, b) => b.price.compareTo(a.price));
    }

    // Apply pagination
    final startIndex = (page - 1) * pageSize;
    if (startIndex >= filteredProducts.length) {
      return []; // No more data
    }

    final endIndex = (startIndex + pageSize).clamp(0, filteredProducts.length);
    return filteredProducts.sublist(startIndex, endIndex);
  }
}

2. Product Grid Widget Implementation

This will be a StatefulWidget to manage the state of products, loading indicators, filters, and sorting options.


import 'package:flutter/material.dart';
// Import your Product and ProductApiService files

class ProductGridWidget extends StatefulWidget {
  const ProductGridWidget({Key? key}) : super(key: key);

  @override
  State<ProductGridWidget> createState() => _ProductGridWidgetState();
}

class _ProductGridWidgetState extends State<ProductGridWidget> {
  final ProductApiService _apiService = ProductApiService();
  final ScrollController _scrollController = ScrollController();

  List<Product> _products = [];
  bool _isLoading = false;
  bool _isLoadingMore = false;
  bool _hasMore = true;
  int _currentPage = 1;
  final int _pageSize = 10;
  String? _selectedCategory;
  SortOrder _selectedSortOrder = SortOrder.none;
  String? _errorMessage;

  final List<String> _categories = ['Electronics', 'Books', 'Clothing'];
  final Map<SortOrder, String> _sortOptions = {
    SortOrder.none: 'None',
    SortOrder.priceAscending: 'Price: Low to High',
    SortOrder.priceDescending: 'Price: High to Low',
  };

  @override
  void initState() {
    super.initState();
    _fetchProducts();
    _scrollController.addListener(_scrollListener);
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  // --- Data Fetching Logic ---
  Future<void> _fetchProducts({bool isInitialLoad = true}) async {
    if (_isLoading || _isLoadingMore) return;

    setState(() {
      if (isInitialLoad) {
        _isLoading = true;
        _errorMessage = null;
      } else {
        _isLoadingMore = true;
      }
    });

    try {
      final List<Product> fetchedProducts = await _apiService.fetchProducts(
        page: _currentPage,
        pageSize: _pageSize,
        categoryFilter: _selectedCategory,
        sortOrder: _selectedSortOrder,
      );

      setState(() {
        if (isInitialLoad) {
          _products = fetchedProducts;
        } else {
          _products.addAll(fetchedProducts);
        }
        _hasMore = fetchedProducts.length == _pageSize;
        _currentPage++;
      });
    } catch (e) {
      setState(() {
        _errorMessage = 'Failed to load products: ${e.toString()}';
      });
    } finally {
      setState(() {
        _isLoading = false;
        _isLoadingMore = false;
      });
    }
  }

  // --- Infinite Scroll Listener ---
  void _scrollListener() {
    if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent &&
        !_isLoadingMore &&
        _hasMore) {
      _fetchProducts(isInitialLoad: false);
    }
  }

  // --- Filter and Sort Actions ---
  void _applyFiltersAndSort() {
    setState(() {
      _products = []; // Clear current products
      _currentPage = 1; // Reset page for new query
      _hasMore = true; // Assume there's more data
    });
    _fetchProducts(); // Fetch with new filters/sort
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Product Catalog'),
      ),
      body: Column(
        children: [
          _buildFilterAndSortControls(),
          Expanded(
            child: _isLoading && _products.isEmpty
                ? const Center(child: CircularProgressIndicator())
                : _errorMessage != null
                    ? Center(
                        child: Text(
                          _errorMessage!,
                          style: const TextStyle(color: Colors.red),
                        ),
                      )
                    : _products.isEmpty
                        ? const Center(child: Text('No products found.'))
                        : RefreshIndicator(
                            onRefresh: () async {
                              setState(() {
                                _products = [];
                                _currentPage = 1;
                                _hasMore = true;
                              });
                              await _fetchProducts();
                            },
                            child: GridView.builder(
                              controller: _scrollController,
                              padding: const EdgeInsets.all(8.0),
                              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                                crossAxisCount: 2,
                                crossAxisSpacing: 8.0,
                                mainAxisSpacing: 8.0,
                                childAspectRatio: 0.75, // Adjust as needed
                              ),
                              itemCount: _products.length + (_isLoadingMore ? 1 : 0),
                              itemBuilder: (context, index) {
                                if (index == _products.length) {
                                  return const Center(child: CircularProgressIndicator());
                                }
                                final product = _products[index];
                                return ProductGridItem(product: product);
                              },
                            ),
                          ),
          ),
        ],
      ),
    );
  }

  Widget _buildFilterAndSortControls() {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          // Category Filter
          Expanded(
            child: DropdownButton<String?>(
              isExpanded: true,
              value: _selectedCategory,
              hint: const Text('Filter by Category'),
              onChanged: (String? newValue) {
                setState(() {
                  _selectedCategory = newValue;
                });
                _applyFiltersAndSort();
              },
              items: [
                const DropdownMenuItem<String?>(
                  value: null,
                  child: Text('All Categories'),
                ),
                ..._categories
                    .map<DropdownMenuItem<String>>((String value) {
                  return DropdownMenuItem<String>(
                    value: value,
                    child: Text(value),
                  );
                }).toList(),
              ],
            ),
          ),
          const SizedBox(width: 16),
          // Sort Order
          Expanded(
            child: DropdownButton<SortOrder>(
              isExpanded: true,
              value: _selectedSortOrder,
              onChanged: (SortOrder? newValue) {
                if (newValue != null) {
                  setState(() {
                    _selectedSortOrder = newValue;
                  });
                  _applyFiltersAndSort();
                }
              },
              items: _sortOptions.entries
                  .map<DropdownMenuItem<SortOrder>>((MapEntry<SortOrder, String> entry) {
                return DropdownMenuItem<SortOrder>(
                  value: entry.key,
                  child: Text(entry.value),
                );
              }).toList(),
            ),
          ),
        ],
      ),
    );
  }
}

// --- Product Grid Item Widget ---
class ProductGridItem extends StatelessWidget {
  final Product product;

  const ProductGridItem({Key? key, required this.product}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 2,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            child: ClipRRect(
              borderRadius: const BorderRadius.vertical(top: Radius.circular(8)),
              child: Image.network(
                product.imageUrl,
                fit: BoxFit.cover,
                width: double.infinity,
                errorBuilder: (context, error, stackTrace) => const Center(child: Icon(Icons.broken_image)),
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  product.name,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
                ),
                const SizedBox(height: 4),
                Text(
                  '\$${product.price.toStringAsFixed(2)}',
                  style: const TextStyle(color: Colors.green, fontSize: 14),
                ),
                const SizedBox(height: 4),
                Text(
                  product.category,
                  style: TextStyle(color: Colors.grey[600], fontSize: 12),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

3. Key Components Explained

State Management

  • _products: The list of products currently displayed.
  • _isLoading: Indicates if the initial product fetch is in progress.
  • _isLoadingMore: Indicates if more products are being loaded for infinite scroll.
  • _hasMore: A flag to check if there are more pages to load.
  • _currentPage: Tracks the current page number for pagination.
  • _selectedCategory: Stores the currently selected category filter.
  • _selectedSortOrder: Stores the currently selected sort order.
  • _errorMessage: For displaying errors to the user.

Data Fetching (_fetchProducts)

This method handles fetching products. It distinguishes between initial loads (clearing _products) and loading more (appending to _products) for infinite scroll. It updates loading flags and handles potential errors.

Infinite Scroll (_scrollListener)

A ScrollController is attached to the GridView.builder. The _scrollListener checks if the user has scrolled to the end of the list and if there are more items to load. If both conditions are met, it triggers _fetchProducts to load the next page.

Filtering and Sorting (_applyFiltersAndSort)

When filter or sort options change, _applyFiltersAndSort is called. This method resets the product list and pagination state (_products, _currentPage, _hasMore) and then re-fetches products with the new criteria.

UI Structure

  • AppBar: Standard app bar.
  • Filter and Sort Controls: DropdownButton widgets are used for selecting category and sort order. These are placed above the grid in a Row.
  • GridView.builder: Efficiently builds a scrollable, 2D array of widgets.
    • controller: Linked to _scrollController for infinite scroll.
    • itemCount: Includes an extra slot for the loading indicator at the bottom if _isLoadingMore is true.
    • itemBuilder: Renders each ProductGridItem or a CircularProgressIndicator if it's the last item and loading more.
  • Loading/Error States: Conditional rendering is used to show a full-screen loading indicator, an error message, or a "No products found" message based on the widget's state.
  • RefreshIndicator: Enables pull-to-refresh functionality, allowing users to manually refresh the product list.
  • ProductGridItem: A separate StatelessWidget to display individual product details in a card format.

Conclusion

This article has demonstrated how to build a dynamic product grid in Flutter, incorporating essential features like filtering, sorting, and infinite scrolling. By separating concerns into a data model, API service, and a stateful UI widget, the solution remains modular and maintainable. For more complex applications, consider integrating state management solutions like Provider, BLoC, or Riverpod to manage the widget's state more robustly.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is