Building a Product Grid Widget with Infinite Scroll in Flutter
Creating engaging user interfaces often involves displaying large datasets efficiently. For e-commerce applications, a common pattern is a product grid, and to enhance user experience, infinite scroll is a must-have feature. Infinite scroll, also known as lazy loading, loads more content as the user scrolls down, preventing the need for pagination and providing a seamless browsing experience. This article will guide you through building a responsive product grid widget with infinite scroll in Flutter.
Key Concepts Behind Infinite Scroll
Implementing infinite scroll in Flutter primarily relies on a few core concepts:
1. Efficient List Rendering with GridView.builder
Flutter's `GridView.builder` is crucial for performance when dealing with potentially endless lists. Unlike `GridView`, `GridView.builder` only renders the widgets that are currently visible on the screen, recycling them as the user scrolls. This significantly reduces memory usage and improves rendering speed.
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // Number of items in a row
childAspectRatio: 0.75, // Aspect ratio of each item
),
itemBuilder: (context, index) {
// Return individual product widget
},
itemCount: _products.length,
)
2. Detecting Scroll Position with ScrollController
To know when to fetch more data, we need to monitor the user's scroll position. `ScrollController` allows us to attach a listener to a scrollable widget (`GridView.builder` in this case). We can then detect when the user has scrolled near the end of the list.
ScrollController _scrollController = ScrollController();
_scrollController.addListener(_scrollListener);
Inside the listener, we check if `_scrollController.position.pixels == _scrollController.position.maxScrollExtent` to determine if the user has reached the bottom.
3. State Management
As the user scrolls and new data is fetched, the UI needs to update. For simplicity, we'll use `setState` in this tutorial. For larger applications, consider more robust state management solutions like Provider, BLoC, Riverpod, or GetX.
Step-by-Step Implementation
Let's dive into the practical implementation.
1. Project Setup
First, create a new Flutter project:
flutter create product_grid_app
cd product_grid_app
2. Define the Product Model
Create a `product_model.dart` file to define the structure of our product data:
// lib/product_model.dart
class Product {
final String id;
final String name;
final double price;
final String imageUrl;
Product({required this.id, required this.name, required this.price, required this.imageUrl});
factory Product.fromJson(Map json) {
return Product(
id: json['id'],
name: json['name'],
price: json['price'].toDouble(),
imageUrl: json['imageUrl'],
);
}
}
3. Create a Mock Product Service
To simulate fetching data from an API, we'll create a `product_service.dart` file. This service will return a fixed number of products per "page" and simulate network delay.
// lib/product_service.dart
import 'dart:async';
import 'dart:math';
import 'package:product_grid_app/product_model.dart'; // Adjust import based on your project structure
class ProductService {
static const int _pageSize = 20; // Number of products to fetch per page
static final List _allProducts = List.generate(
200, // Total number of products available
(index) => Product(
id: 'p${index + 1}',
name: 'Product ${index + 1}',
price: (10.0 + index * 0.5).toPrecision(2),
imageUrl: 'https://picsum.photos/id/${index + 10}/200/300', // Example image URL
),
);
Future> fetchProducts(int pageKey, [int? pageSize]) async {
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 1500));
final int effectivePageSize = pageSize ?? _pageSize;
final int startIndex = pageKey * effectivePageSize;
final int endIndex = min(startIndex + effectivePageSize, _allProducts.length);
if (startIndex >= _allProducts.length) {
return []; // No more data to fetch
}
return _allProducts.sublist(startIndex, endIndex);
}
}
// Helper extension for double precision
extension on double {
double toPrecision(int fractionDigits) {
num fac = pow(10, fractionDigits);
return (this * fac).round() / fac;
}
}
4. Build the Product Grid Widget
Now, let's create the main widget for our product grid with infinite scroll. Create `product_grid_screen.dart` inside the `lib` folder.
a. Initial Structure and Logic
This widget will be a `StatefulWidget` to manage the list of products, loading state, and scroll controller.
// lib/product_grid_screen.dart
import 'package:flutter/material.dart';
import 'package:product_grid_app/product_model.dart';
import 'package:product_grid_app/product_service.dart';
class ProductGridScreen extends StatefulWidget {
const ProductGridScreen({Key? key}) : super(key: key);
@override
State createState() => _ProductGridScreenState();
}
class _ProductGridScreenState extends State {
final ProductService _productService = ProductService();
final List _products = [];
final ScrollController _scrollController = ScrollController();
bool _isLoading = false;
bool _hasMore = true; // Indicates if there are more products to load
int _currentPage = 0; // Tracks the current page number for fetching
@override
void initState() {
super.initState();
_fetchProducts(); // Fetch initial products
_scrollController.addListener(_scrollListener); // Attach scroll listener
}
@override
void dispose() {
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
super.dispose();
}
// Method to fetch products from the service
Future _fetchProducts() async {
if (_isLoading || !_hasMore) return; // Prevent multiple simultaneous fetches or fetching when no more data
setState(() {
_isLoading = true; // Set loading state to true
});
try {
final newProducts = await _productService.fetchProducts(_currentPage);
if (newProducts.isEmpty) {
_hasMore = false; // No more products available
} else {
_products.addAll(newProducts);
_currentPage++; // Increment page number for next fetch
}
} catch (e) {
// Handle error (e.g., show a Snackbar or error message)
print('Error fetching products: $e');
_hasMore = false; // Prevent further loading on error
} finally {
setState(() {
_isLoading = false; // Set loading state to false
});
}
}
// Listener for scroll events
void _scrollListener() {
// Check if the user has scrolled to the bottom of the list
if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent && !_isLoading) {
_fetchProducts(); // Fetch more products
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Product Grid with Infinite Scroll'),
),
body: GridView.builder(
controller: _scrollController,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // Two items per row
childAspectRatio: 0.75, // Adjust as needed for product cards
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
padding: const EdgeInsets.all(10),
itemCount: _products.length + (_isLoading || !_hasMore ? 1 : 0), // Add 1 for loader/end indicator
itemBuilder: (context, index) {
if (index < _products.length) {
final product = _products[index];
return Card(
elevation: 2,
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Image.network(
product.imageUrl,
fit: BoxFit.cover,
width: double.infinity,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null,
),
);
},
errorBuilder: (context, error, stackTrace) {
return const Center(child: Icon(Icons.broken_image, size: 50, color: Colors.grey));
},
),
),
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, bottom: 8.0),
child: Text('\$${product.price.toStringAsFixed(2)}', style: const TextStyle(color: Colors.green)),
),
],
),
);
} else {
// This is the loading indicator or "no more products" message
return Center(
child: _isLoading
? const CircularProgressIndicator() // Show loader
: _hasMore
? const SizedBox.shrink() // Should not happen if logic is correct, but safe fallback
: const Text('You have reached the end of the list!'), // Show end message
);
}
},
),
);
}
}
b. Integrating into main.dart
Finally, update your `main.dart` file to run the `ProductGridScreen`.
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:product_grid_app/product_grid_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Product Grid',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const ProductGridScreen(),
debugShowCheckedModeBanner: false,
);
}
}
Run the application, and you'll see a product grid that loads more items as you scroll down!
Enhancements and Considerations
Error Handling and UI Feedback
- Snackbar/Toast: Display a `SnackBar` when there's an error fetching products.
- Retry Button: If an error occurs, consider showing a "Retry" button at the bottom instead of just "No more products".
- Empty State: Handle the scenario where the initial fetch returns no products.
Optimizing Performance
- Debouncing Scroll Listener: For very heavy lists or complex item builders, you might debounce the `_scrollListener` to prevent `_fetchProducts` from being called too frequently.
- Image Caching: Use packages like `cached_network_image` for efficient image loading and caching.
- SliverGrid vs. GridView: For more complex scrollable effects, consider using `CustomScrollView` with `SliverGrid`.
State Management Solutions
While `setState` is sufficient for this example, for applications with more complex state logic, consider:
- Provider: A simple yet powerful solution for dependency injection and state management.
- BLoC/Cubit: For predictable state management and separation of concerns.
- Riverpod: A compile-time safe alternative to Provider.
- GetX: A complete solution for state management, dependency injection, and routing.
Conclusion
You have successfully built a product grid widget with infinite scroll in Flutter. This pattern is fundamental for modern applications that deal with dynamic, large datasets. By leveraging `GridView.builder` for efficiency, `ScrollController` for scroll detection, and basic state management, you can provide users with a smooth and engaging browsing experience. Remember to consider error handling, performance optimizations, and appropriate state management for production-ready applications.