image

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

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