Building a Product Grid Widget with Infinite Scroll in Flutter

18 Mar 2026

9K

35K

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.

Related Articles

Aug 02, 2026

Building a Flutter Notification Center with Grouping & Actions

Learn to create a sophisticated in-app notification center in Flutter, featuring grouping, swipe-to-dismiss, custom icons, and actionable buttons for enhanced u

Aug 01, 2026

Flutter Layout Tips: Using FractionallySizedBox, AspectRatio, ConstrainedBox for Adaptive UI

Master Flutter's FractionallySizedBox, AspectRatio, and ConstrainedBox to build truly adaptive UIs. Learn how to control widget sizing, maintain proportions, an

Aug 01, 2026

Flutter Product Quick View: Animation, Related Items, Badges

Learn to create a dynamic product quick view widget in Flutter, incorporating smooth animations, relevant related items, and eye-catching promo badges for an en