Build a Dynamic Product Grid in Flutter with Infinite Scroll, Filter, and Sort

31 Jul 2026

9K

35K

Build a Dynamic Product Grid in Flutter with Infinite Scroll, Filter, and Sort

Creating an engaging product display is fundamental for any e-commerce or catalog application. A dynamic product grid in Flutter, equipped with features like infinite scrolling, filtering, and sorting, significantly enhances user experience by making navigation intuitive and efficient. This article will guide you through building such a robust product grid, covering the essential components and best practices to ensure your application is both performant and user-friendly.

You will learn how to structure your data, implement a scroll-controlled loading mechanism, integrate various filtering options, and enable dynamic sorting, all while maintaining a clean and maintainable Flutter codebase.

Prerequisites

Before diving into the implementation, ensure you have the following:

  • Flutter SDK: Installed and configured on your development machine.
  • Basic Flutter Knowledge: Familiarity with widgets, state management (e.g., setState or Provider), and asynchronous programming.
  • Dart Fundamentals: Understanding of classes, asynchronous operations, and collections.

Setting Up the Project and Data Model

First, create a new Flutter project and define a simple data model for your products. For this tutorial, we'll use a mock data service instead of a real API to keep the focus on the UI and logic.

Product Data Model

Define a Product class to represent each item in your grid:

class Product {  final String id;  final String name;  final String imageUrl;  final double price;  final String category;  Product({    required this.id,    required this.name,    required this.imageUrl,    required this.price,    required this.category,  });  // For simplicity, we'll use a mock data service, so fromJson isn't strictly needed  // but good practice for real API integration.  factory Product.fromJson(Map<String, dynamic> json) {    return Product(      id: json['id'],      name: json['name'],      imageUrl: json['imageUrl'],      price: json['price'].toDouble(),      category: json['category'],    );  }}

Mock Product Service

Create a service to simulate fetching products, including pagination for infinite scroll:

class ProductService {  static final List<Product> _allProducts = List.generate(    100, // Total mock products    (index) => Product(      id: 'p${index + 1}',      name: 'Product ${index + 1}',      imageUrl: 'https://via.placeholder.com/150?text=Product+${index + 1}',      price: 10.0 + index,      category: index % 3 == 0 ? 'Electronics' : (index % 3 == 1 ? 'Books' : 'Clothing'),    ),  );  Future<List<Product>> fetchProducts({    int page = 0,    int limit = 10,    String? categoryFilter,    String? sortBy, // 'price_asc', 'price_desc', 'name_asc'    String? searchKeyword,  }) async {    await Future.delayed(const Duration(milliseconds: 700)); // Simulate network delay    List<Product> filteredProducts = _allProducts;    // Apply category filter    if (categoryFilter != null && categoryFilter != 'All') {      filteredProducts = filteredProducts          .where((product) => product.category == categoryFilter)          .toList();    }    // Apply search keyword filter    if (searchKeyword != null && searchKeyword.isNotEmpty) {      filteredProducts = filteredProducts          .where((product) => product.name.toLowerCase().contains(searchKeyword.toLowerCase()))          .toList();    }    // Apply sorting    if (sortBy != null) {      filteredProducts.sort((a, b) {        if (sortBy == 'price_asc') {          return a.price.compareTo(b.price);        } else if (sortBy == 'price_desc') {          return b.price.compareTo(a.price);        } else if (sortBy == 'name_asc') {          return a.name.compareTo(b.name);        }        return 0;      });    }    final startIndex = page * limit;    if (startIndex >= filteredProducts.length) {      return []; // No more products    }    final endIndex = (startIndex + limit).clamp(0, filteredProducts.length);    return filteredProducts.sublist(startIndex, endIndex);  }}

Building the Product Grid Widget

Now, let's create the main widget that will display our products and manage their state.

import 'package:flutter/material.dart';// Assume Product and ProductService are defined in product_model.dart and product_service.dart// For this example, they are in the same file.class ProductGridScreen extends StatefulWidget {  const ProductGridScreen({super.key});  @override  State<ProductGridScreen> createState() => _ProductGridScreenState();}class _ProductGridScreenState extends State<ProductGridScreen> {  final ProductService _productService = ProductService();  final ScrollController _scrollController = ScrollController();  List<Product> _products = [];  bool _isLoading = false;  bool _hasMore = true;  int _currentPage = 0;  String? _selectedCategory = 'All';  String? _selectedSortBy = 'name_asc';  final TextEditingController _searchController = TextEditingController();  String _searchKeyword = '';  @override  void initState() {    super.initState();    _fetchProducts();    _scrollController.addListener(_onScroll);    _searchController.addListener(() {      // Debounce search input for better performance      if (_searchController.text != _searchKeyword) {        Future.delayed(const Duration(milliseconds: 500), () {          if (_searchController.text == _searchKeyword) return; // Prevent multiple calls          setState(() {            _searchKeyword = _searchController.text;            _resetAndFetchProducts();          });        });      }    });  }  @override  void dispose() {    _scrollController.dispose();    _searchController.dispose();    super.dispose();  }  void _onScroll() {    if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent &&        !_isLoading &&        _hasMore) {      _fetchProducts();    }  }  Future<void> _fetchProducts() async {    if (_isLoading) return;    setState(() {      _isLoading = true;    });    try {      final newProducts = await _productService.fetchProducts(        page: _currentPage,        limit: 10,        categoryFilter: _selectedCategory,        sortBy: _selectedSortBy,        searchKeyword: _searchKeyword,      );      setState(() {        _products.addAll(newProducts);        _currentPage++;        _hasMore = newProducts.isNotEmpty;        _isLoading = false;      });    } catch (e) {      // Handle error, e.g., show a SnackBar      print('Error fetching products: $e');      setState(() {        _isLoading = false;      });    }  }  void _resetAndFetchProducts() {    setState(() {      _products.clear();      _currentPage = 0;      _hasMore = true;      _isLoading = false; // Ensure it's false before fetching      _fetchProducts();    });  }  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: const Text('Dynamic Product Grid'),      ),      body: Column(        children: [          Padding(            padding: const EdgeInsets.all(8.0),            child: TextField(              controller: _searchController,              decoration: const InputDecoration(                labelText: 'Search Products',                border: OutlineInputBorder(),                prefixIcon: Icon(Icons.search),              ),            ),          ),          _buildFilterAndSortControls(),          Expanded(            child: GridView.builder(              controller: _scrollController,              padding: const EdgeInsets.all(8.0),              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(                crossAxisCount: 2,                crossAxisSpacing: 8.0,                mainAxisSpacing: 8.0,                childAspectRatio: 0.7,              ),              itemCount: _products.length + (_isLoading ? 1 : 0),              itemBuilder: (context, index) {                if (index < _products.length) {                  final product = _products[index];                  return Card(                    elevation: 2,                    child: Column(                      crossAxisAlignment: CrossAxisAlignment.start,                      children: [                        Expanded(                          child: Image.network(                            product.imageUrl,                            fit: BoxFit.cover,                            width: double.infinity,                          ),                        ),                        Padding(                          padding: const EdgeInsets.all(8.0),                          child: Text(                            product.name,                            style: const TextStyle(fontWeight: FontWeight.bold),                            maxLines: 1,                            overflow: TextOverflow.ellipsis,                          ),                        ),                        Padding(                          padding: const EdgeInsets.symmetric(horizontal: 8.0),                          child: Text('
$${product.price.toStringAsFixed(2)}'),                        ),                        Padding(                          padding: const EdgeInsets.fromLTRB(8.0, 0, 8.0, 8.0),                          child: Text(                            product.category,                            style: const TextStyle(color: Colors.grey),                          ),                        ),                      ],                    ),                  );                } else {                  return const Center(child: CircularProgressIndicator());                }              },            ),          ),        ],      ),    );  }  Widget _buildFilterAndSortControls() {    return Padding(      padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),      child: Row(        children: [          Expanded(            child: DropdownButton<String>(              isExpanded: true,              value: _selectedCategory,              hint: const Text('Filter by Category'),              onChanged: (String? newValue) {                setState(() {                  _selectedCategory = newValue;                  _resetAndFetchProducts();                });              },              items: <String>['All', 'Electronics', 'Books', 'Clothing']                  .map<DropdownMenuItem<String>>((String value) {                return DropdownMenuItem<String>(                  value: value,                  child: Text(value),                );              }).toList(),            ),          ),          const SizedBox(width: 10),          Expanded(            child: DropdownButton<String>(              isExpanded: true,              value: _selectedSortBy,              hint: const Text('Sort By'),              onChanged: (String? newValue) {                setState(() {                  _selectedSortBy = newValue;                  _resetAndFetchProducts();                });              },              items: <String>[                'name_asc',                'price_asc',                'price_desc',              ].map<DropdownMenuItem<String>>((String value) {                return DropdownMenuItem<String>(                  value: value == 'name_asc'                      ? 'Name (A-Z)'                      : (value == 'price_asc'                          ? 'Price (Low to High)'                          : 'Price (High to Low)'),                  child: Text(value == 'name_asc'                      ? 'Name (A-Z)'                      : (value == 'price_asc'                          ? 'Price (Low to High)'                          : 'Price (High to Low)')),                );              }).toList(),            ),          ),        ],      ),    );  }}

Explanation of Key Components

1. Infinite Scroll

  • ScrollController: Attached to the GridView.builder, it listens for scroll events.
  • _onScroll Method: Checks if the user has scrolled to the end of the list (_scrollController.position.maxScrollExtent). If so, and if not already loading and more data is available (_hasMore), it triggers _fetchProducts().
  • _isLoading Flag: Prevents multiple simultaneous data fetches.
  • Loading Indicator: A CircularProgressIndicator is shown at the end of the grid when _isLoading is true and _hasMore is true, providing visual feedback.

2. Filtering

  • _selectedCategory & _searchKeyword: State variables to hold the current filter criteria.
  • DropdownButton: Provides a UI for selecting a category. When the value changes, _resetAndFetchProducts() is called.
  • TextField: Allows users to type in a search keyword. A debounce mechanism (using Future.delayed) is added to prevent excessive API calls on every keystroke, only triggering a fetch after a brief pause in typing.
  • ProductService.fetchProducts: The mock service applies the categoryFilter and searchKeyword to the data before returning a paginated subset.

3. Sorting

  • _selectedSortBy: A state variable to store the currently selected sorting option (e.g., 'price_asc', 'name_asc').
  • DropdownButton: Offers options for sorting the products. Similar to filtering, changing this value triggers _resetAndFetchProducts().
  • ProductService.fetchProducts: The service sorts the data based on sortBy before pagination.

4. Combining Features

The crucial part is how these features interact. When a filter or sort option changes, or a search keyword is entered, the _resetAndFetchProducts() method is called. This method:

  1. Clears the existing _products list.
  2. Resets _currentPage to 0.
  3. Sets _hasMore to true.
  4. Triggers a fresh call to _fetchProducts() with the new filter/sort/search parameters.

This ensures that any change in criteria starts a new data fetch from the beginning, applying all selected filters and sorting before pagination.

Best Practices and Common Pitfalls

  • Debouncing Input: For search fields, implement debouncing to avoid making an API call on every single keystroke. This reduces server load and improves responsiveness.
  • Error Handling: Always include error handling (e.g., try-catch blocks) for data fetching operations. Display user-friendly messages if an API call fails.
  • Loading States: Provide clear visual feedback (e.g., loading indicators) when data is being fetched, especially during infinite scroll.
  • Empty States: Design an appropriate UI for when no products match the current filters or search criteria.
  • Performance Optimization: For very large grids, consider using const widgets where possible and optimize your item builder to avoid unnecessary rebuilds.
  • State Management: For more complex applications, consider a dedicated state management solution like Provider, BLoC, or Riverpod to manage the product list and filtering/sorting logic more robustly.

Conclusion

You've successfully built a dynamic product grid in Flutter, integrating essential features like infinite scrolling, filtering, and sorting. This robust foundation can be adapted for various applications requiring efficient data display and user interaction. By following these patterns, you can create highly performant and user-friendly interfaces that scale well with growing data sets.

Experiment with different filtering options, sorting criteria, and UI layouts to further enhance your product grid. Consider adding features like pull-to-refresh or more sophisticated search capabilities next.

FAQ

Q1: How can I improve the performance of a very large product grid?

Ensure you are using GridView.builder (or ListView.builder) as it efficiently builds items only when they are visible. Use const constructors for widgets within your grid items where possible to prevent unnecessary rebuilds. Optimize your image loading and caching, and keep your data models lean.

Q2: What if my API doesn't support filtering or sorting directly?

If your backend API doesn't offer server-side filtering or sorting, you'll have to fetch all relevant data and perform these operations client-side. Be cautious with very large datasets, as client-side processing can impact performance. For large data, consider implementing a backend solution or using a database that supports these queries.

Q3: How do I handle network errors or empty results gracefully?

Implement try-catch blocks around your data fetching logic. On error, display a user-friendly message or a retry button. If the fetched list is empty after applying filters/sorts, show an

Related Articles

Jul 31, 2026

Build a Dynamic Product Grid in Flutter with Infinite Scroll, Filter, and Sort

Learn how to create a robust product grid in Flutter, integrating infinite scrolling for seamless loading, advanced filtering, and sorting options for enhanced

Jul 30, 2026

Build Interactive Flashcards in Flutter with Swipe & Flip Animations

Learn to create dynamic flashcard widgets in Flutter, incorporating smooth swipe gestures, realistic flip animations, and engaging interactive elements for a ri

Jul 30, 2026

Flutter & Riverpod: State Management for Multi-Feature Social Apps

Master Flutter and Riverpod to build scalable social media apps. Learn effective state management strategies for complex features like feeds, profiles, and chat