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.,
setStateorProvider), 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 theGridView.builder, it listens for scroll events._onScrollMethod: 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()._isLoadingFlag: Prevents multiple simultaneous data fetches.- Loading Indicator: A
CircularProgressIndicatoris shown at the end of the grid when_isLoadingis true and_hasMoreis 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 (usingFuture.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 thecategoryFilterandsearchKeywordto 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 onsortBybefore 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:
- Clears the existing
_productslist. - Resets
_currentPageto 0. - Sets
_hasMoreto true. - 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-catchblocks) 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
constwidgets 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