Skip to content

Infinite Scrolling

Understand how to implement infinite scrolling (endless scrolling) in Flutter.


What is it?

Infinite scrolling is a technique where new content is loaded automatically as the user approaches the end of the current content. This creates a seamless, endless scrolling experience commonly seen in social media feeds, search results, and product lists. When the user scrolls to the bottom, more data is loaded, providing a continuous stream of content.


Why does it exist?

Infinite scrolling exists to:

  • Load content on demand as the user scrolls
  • Improve performance by loading small batches
  • Create seamless user experiences
  • Handle large datasets efficiently
  • Implement feeds and timelines
  • Reduce initial load time
  • Enable smooth browsing without pagination

Basic Infinite Scrolling

Loading more content as user scrolls.

// Basic infinite scrolling example
class BasicInfiniteScroll extends StatefulWidget {
  const BasicInfiniteScroll({super.key});

  @override
  State<BasicInfiniteScroll> createState() => _BasicInfiniteScrollState();
}

class _BasicInfiniteScrollState extends State<BasicInfiniteScroll> {
  final ScrollController _controller = ScrollController();
  List<int> _items = [];
  bool _isLoading = false;
  bool _hasMore = true;
  int _page = 0;

  @override
  void initState() {
    super.initState();
    _loadMore();
    _controller.addListener(() {
      if (_controller.position.pixels >= _controller.position.maxScrollExtent - 200) {
        _loadMore();
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _loadMore() async {
    if (_isLoading || !_hasMore) return;

    setState(() {
      _isLoading = true;
    });

    try {
      // Simulate network request
      await Future.delayed(const Duration(seconds: 1));

      final newItems = List.generate(20, (index) => _page * 20 + index);
      _items.addAll(newItems);
      _page++;

      // Simulate end of data (after 5 pages)
      if (_page >= 5) {
        _hasMore = false;
      }

      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    } catch (e) {
      if (mounted) {
        setState(() {
          _isLoading = false;
          // Handle error
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Infinite Scroll'),
      ),
      body: ListView.builder(
        controller: _controller,
        itemCount: _items.length + (_isLoading ? 1 : 0),
        itemBuilder: (context, index) {
          if (index == _items.length) {
            return const Padding(
              padding: EdgeInsets.symmetric(vertical: 16),
              child: Center(child: CircularProgressIndicator()),
            );
          }
          return Container(
            height: 60,
            color: Colors.blue[100 * (_items[index] % 9 + 1)],
            margin: const EdgeInsets.all(4),
            child: Center(
              child: Text(
                'Item ${_items[index]}',
                style: const TextStyle(fontWeight: FontWeight.bold),
              ),
            ),
          );
        },
      ),
    );
  }
}

What's happening here? - Load more when near bottom - Show loading indicator - Track loading state - _hasMore controls whether to load more - Uses ScrollController to detect scroll position


Infinite Scrolling with Data Models

Loading structured data with infinite scroll.

// Infinite scrolling with data models
class Post {
  final int id;
  final String title;
  final String body;
  final String author;

  Post({
    required this.id,
    required this.title,
    required this.body,
    required this.author,
  });
}

class InfiniteScrollWithData extends StatefulWidget {
  const InfiniteScrollWithData({super.key});

  @override
  State<InfiniteScrollWithData> createState() => _InfiniteScrollWithDataState();
}

class _InfiniteScrollWithDataState extends State<InfiniteScrollWithData> {
  final ScrollController _controller = ScrollController();
  List<Post> _posts = [];
  bool _isLoading = false;
  bool _hasMore = true;
  int _page = 1;

  @override
  void initState() {
    super.initState();
    _loadMore();
    _controller.addListener(() {
      if (_controller.position.pixels >= _controller.position.maxScrollExtent - 200) {
        _loadMore();
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _loadMore() async {
    if (_isLoading || !_hasMore) return;

    setState(() => _isLoading = true);

    try {
      // Simulate API call
      await Future.delayed(const Duration(seconds: 1));

      final newPosts = List.generate(10, (index) {
        final id = (_page - 1) * 10 + index;
        return Post(
          id: id,
          title: 'Post ${id + 1}',
          body: 'This is the body of post ${id + 1}. It contains some content.',
          author: 'User ${id % 5 + 1}',
        );
      });

      _posts.addAll(newPosts);
      _page++;

      if (_page > 5) _hasMore = false;

      if (mounted) {
        setState(() => _isLoading = false);
      }
    } catch (e) {
      if (mounted) {
        setState(() => _isLoading = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Feed'),
      ),
      body: ListView.builder(
        controller: _controller,
        itemCount: _posts.length + (_isLoading ? 1 : 0),
        itemBuilder: (context, index) {
          if (index == _posts.length) {
            return const Padding(
              padding: EdgeInsets.all(16),
              child: Center(child: CircularProgressIndicator()),
            );
          }

          final post = _posts[index];
          return Card(
            margin: const EdgeInsets.all(8),
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    children: [
                      CircleAvatar(
                        backgroundColor: Colors.blue[100],
                        child: Text(post.author[0]),
                      ),
                      const SizedBox(width: 12),
                      Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(
                              post.author,
                              style: const TextStyle(
                                fontWeight: FontWeight.bold,
                              ),
                            ),
                            Text(
                              'Post ${post.id + 1}',
                              style: TextStyle(
                                color: Colors.grey[600],
                                fontSize: 12,
                              ),
                            ),
                          ],
                        ),
                      ),
                    ],
                  ),
                  const SizedBox(height: 12),
                  Text(
                    post.title,
                    style: const TextStyle(
                      fontSize: 18,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    post.body,
                    style: TextStyle(
                      color: Colors.grey[700],
                      fontSize: 14,
                      height: 1.5,
                    ),
                  ),
                  const SizedBox(height: 12),
                  Row(
                    children: [
                      IconButton(
                        icon: const Icon(Icons.favorite_border),
                        onPressed: () {},
                      ),
                      const Text('25'),
                      const SizedBox(width: 16),
                      IconButton(
                        icon: const Icon(Icons.comment_outlined),
                        onPressed: () {},
                      ),
                      const Text('8'),
                    ],
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

What's happening here? - Structured data (Post model) - Cards with rich content - User avatar, title, body - Interactive buttons - Page-based loading


Infinite Scrolling with Error Handling

Handling errors in infinite scrolling.

// Infinite scrolling with error handling
class InfiniteScrollWithError extends StatefulWidget {
  const InfiniteScrollWithError({super.key});

  @override
  State<InfiniteScrollWithError> createState() => _InfiniteScrollWithErrorState();
}

class _InfiniteScrollWithErrorState extends State<InfiniteScrollWithError> {
  final ScrollController _controller = ScrollController();
  List<int> _items = [];
  bool _isLoading = false;
  bool _hasMore = true;
  int _page = 0;
  String? _error;
  bool _initialLoad = true;

  @override
  void initState() {
    super.initState();
    _loadMore();
    _controller.addListener(() {
      if (_controller.position.pixels >= _controller.position.maxScrollExtent - 200) {
        _loadMore();
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _loadMore() async {
    if (_isLoading || !_hasMore) return;

    setState(() {
      _isLoading = true;
      _error = null;
    });

    try {
      // Simulate API with random errors
      await Future.delayed(const Duration(seconds: 1));

      // Randomly fail
      if (_page % 4 == 3) {
        throw Exception('Failed to load data');
      }

      final newItems = List.generate(15, (index) => _page * 15 + index);
      _items.addAll(newItems);
      _page++;

      if (_page >= 6) _hasMore = false;

      if (mounted) {
        setState(() {
          _isLoading = false;
          _initialLoad = false;
        });
      }
    } catch (e) {
      if (mounted) {
        setState(() {
          _isLoading = false;
          _error = e.toString();
          _initialLoad = false;
        });
      }
    }
  }

  Future<void> _retry() async {
    if (_error != null) {
      await _loadMore();
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_initialLoad && _isLoading) {
      return const Scaffold(
        body: Center(child: CircularProgressIndicator()),
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Infinite Scroll with Error'),
        actions: [
          if (_error != null)
            IconButton(
              icon: const Icon(Icons.refresh),
              onPressed: _retry,
            ),
        ],
      ),
      body: Column(
        children: [
          if (_error != null)
            Container(
              padding: const EdgeInsets.all(16),
              color: Colors.red[50],
              child: Row(
                children: [
                  const Icon(Icons.error, color: Colors.red),
                  const SizedBox(width: 8),
                  Expanded(
                    child: Text(
                      'Error: $_error',
                      style: const TextStyle(color: Colors.red),
                    ),
                  ),
                  TextButton(
                    onPressed: _retry,
                    child: const Text('Retry'),
                  ),
                ],
              ),
            ),
          Expanded(
            child: ListView.builder(
              controller: _controller,
              itemCount: _items.length + (_isLoading ? 1 : 0),
              itemBuilder: (context, index) {
                if (index == _items.length) {
                  return const Padding(
                    padding: EdgeInsets.all(16),
                    child: Center(child: CircularProgressIndicator()),
                  );
                }
                return Container(
                  height: 60,
                  color: Colors.blue[100 * (_items[index] % 9 + 1)],
                  margin: const EdgeInsets.all(4),
                  child: Center(
                    child: Text(
                      'Item ${_items[index]}',
                      style: const TextStyle(fontWeight: FontWeight.bold),
                    ),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

What's happening here? - Error handling with retry - Error banner at top - Retry button in app bar - Graceful error recovery - Initial loading state


Real-World Examples

Common patterns for infinite scrolling.

// 1. Search with infinite scrolling
class SearchWithInfiniteScroll extends StatefulWidget {
  const SearchWithInfiniteScroll({super.key});

  @override
  State<SearchWithInfiniteScroll> createState() => _SearchWithInfiniteScrollState();
}

class _SearchWithInfiniteScrollState extends State<SearchWithInfiniteScroll> {
  final ScrollController _controller = ScrollController();
  final TextEditingController _searchController = TextEditingController();
  List<String> _results = [];
  bool _isLoading = false;
  bool _hasMore = true;
  int _page = 0;
  String _query = '';

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      if (_controller.position.pixels >= _controller.position.maxScrollExtent - 200) {
        _searchMore();
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    _searchController.dispose();
    super.dispose();
  }

  Future<void> _searchMore() async {
    if (_isLoading || !_hasMore || _query.isEmpty) return;

    setState(() => _isLoading = true);

    try {
      await Future.delayed(const Duration(seconds: 1));

      final newResults = List.generate(
        10,
        (index) => '${_query} result ${_page * 10 + index + 1}',
      );

      _results.addAll(newResults);
      _page++;

      if (_page >= 5) _hasMore = false;

      if (mounted) {
        setState(() => _isLoading = false);
      }
    } catch (e) {
      if (mounted) {
        setState(() => _isLoading = false);
      }
    }
  }

  void _performSearch(String query) {
    setState(() {
      _query = query;
      _results = [];
      _page = 0;
      _hasMore = true;
    });
    if (query.isNotEmpty) {
      _searchMore();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Search'),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(8),
            child: TextField(
              controller: _searchController,
              decoration: InputDecoration(
                hintText: 'Search...',
                prefixIcon: const Icon(Icons.search),
                suffixIcon: IconButton(
                  icon: const Icon(Icons.clear),
                  onPressed: () {
                    _searchController.clear();
                    _performSearch('');
                  },
                ),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(8),
                ),
              ),
              onChanged: _performSearch,
            ),
          ),
          Expanded(
            child: _results.isEmpty && !_isLoading
                ? Center(
                    child: Text(
                      _query.isEmpty ? 'Search for something' : 'No results found',
                      style: TextStyle(
                        color: Colors.grey[600],
                      ),
                    ),
                  )
                : ListView.builder(
                    controller: _controller,
                    itemCount: _results.length + (_isLoading ? 1 : 0),
                    itemBuilder: (context, index) {
                      if (index == _results.length) {
                        return const Padding(
                          padding: EdgeInsets.all(16),
                          child: Center(child: CircularProgressIndicator()),
                        );
                      }
                      return ListTile(
                        leading: CircleAvatar(
                          child: Text('${index + 1}'),
                        ),
                        title: Text(_results[index]),
                        onTap: () {},
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

// 2. Grid with infinite scrolling (Instagram-like)
class InfiniteGridScroll extends StatefulWidget {
  const InfiniteGridScroll({super.key});

  @override
  State<InfiniteGridScroll> createState() => _InfiniteGridScrollState();
}

class _InfiniteGridScrollState extends State<InfiniteGridScroll> {
  final ScrollController _controller = ScrollController();
  List<int> _items = [];
  bool _isLoading = false;
  bool _hasMore = true;
  int _page = 0;

  @override
  void initState() {
    super.initState();
    _loadMore();
    _controller.addListener(() {
      if (_controller.position.pixels >= _controller.position.maxScrollExtent - 200) {
        _loadMore();
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _loadMore() async {
    if (_isLoading || !_hasMore) return;

    setState(() => _isLoading = true);

    await Future.delayed(const Duration(seconds: 1));
    final newItems = List.generate(12, (index) => _page * 12 + index);
    _items.addAll(newItems);
    _page++;

    if (_page >= 5) _hasMore = false;

    if (mounted) {
      setState(() => _isLoading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Photo Grid'),
      ),
      body: GridView.builder(
        controller: _controller,
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          crossAxisSpacing: 2,
          mainAxisSpacing: 2,
        ),
        itemCount: _items.length + (_isLoading ? 1 : 0),
        itemBuilder: (context, index) {
          if (index == _items.length) {
            return const Center(child: CircularProgressIndicator());
          }
          return Container(
            color: Colors.blue[100 * (_items[index] % 9 + 1)],
            child: Center(
              child: Text(
                '${_items[index] + 1}',
                style: const TextStyle(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

What's happening here? - Search with pagination - Grid view infinite scrolling - Real-world social media patterns - Search and filter functionality


Best Practices

Use Controller for Scroll Detection

// Good - Listen to scroll events
@override
void initState() {
  super.initState();
  _controller.addListener(() {
    if (_controller.position.pixels >= _controller.position.maxScrollExtent - 200) {
      _loadMore();
    }
  });
}

Show Loading Indicator

// Good - Show loading state
if (index == _items.length) {
  return const Padding(
    padding: EdgeInsets.all(16),
    child: Center(child: CircularProgressIndicator()),
  );
}

Handle Errors Gracefully

// Good - Error handling
try {
  await _loadMore();
} catch (e) {
  setState(() {
    _error = e.toString();
  });
}

Common Mistakes

Not Checking Loading State

Wrong:

// Multiple simultaneous requests
void _loadMore() {
  // No loading check
  loadData();
}

Correct:

// Check loading state
void _loadMore() {
  if (_isLoading) return;
  // Load data
}

Not Handling End of Data

Wrong:

// Infinite loading even when no data
void _loadMore() {
  // No end check
  loadMoreData();
}

Correct:

// Check for end of data
void _loadMore() {
  if (!_hasMore) return;
  loadMoreData();
}


Summary

Infinite scrolling loads content automatically as the user scrolls down. Use ScrollController to detect when the user approaches the end of the list, show loading indicators, handle errors gracefully, and stop loading when no more data exists.


Next Steps


Did You Know?

  • Infinite scrolling improves UX by reducing pagination
  • ScrollController detects scroll position
  • Loading state prevents duplicate requests
  • Error handling is essential for reliability
  • Grid views also support infinite scrolling
  • Search results use infinite scrolling
  • Social media feeds use infinite scrolling
  • Infinite scrolling is common in modern apps