Examples

Infinite Scroll

Learn how to implement infinite scroll with throttle using Shard.

Overview

This example demonstrates infinite scroll with:

  • Throttle to prevent excessive API calls
  • Loading states
  • Error handling
  • State management

State Model

class InfiniteScrollState {
  final List<Todo> items;
  final bool isLoading;
  final bool hasMore;
  final int page;
  final String? error;
  final int throttleCount;

  const InfiniteScrollState({
    required this.items,
    required this.isLoading,
    required this.hasMore,
    required this.page,
    this.error,
    this.throttleCount = 0,
  });

  InfiniteScrollState copyWith({
    List<Todo>? items,
    bool? isLoading,
    bool? hasMore,
    int? page,
    String? error,
    int? throttleCount,
  }) {
    return InfiniteScrollState(
      items: items ?? this.items,
      isLoading: isLoading ?? this.isLoading,
      hasMore: hasMore ?? this.hasMore,
      page: page ?? this.page,
      error: error ?? this.error,
      throttleCount: throttleCount ?? this.throttleCount,
    );
  }
}

Shard

import 'dart:async';
import 'package:shard/shard.dart';
import '../models/todo.dart';
import '../models/infinite_scroll_state.dart';

class InfiniteScrollShard extends Shard<InfiniteScrollState> {
  InfiniteScrollShard()
      : super(
          const InfiniteScrollState(
            items: [],
            isLoading: false,
            hasMore: true,
            page: 0,
          ),
        );

  Future<void> loadMore() async {
    // Use throttle to prevent multiple rapid calls
    final wasExecuted = throttle(
      'loadMore',
      () {
        _performLoadMore();
      },
      duration: const Duration(seconds: 1),
    );

    // If throttle prevented execution, increment throttle count
    if (!wasExecuted) {
      emit(state.copyWith(throttleCount: state.throttleCount + 1));
    }
  }

  Future<void> _performLoadMore() async {
    // Check if already loading or no more items
    if (state.isLoading || !state.hasMore) {
      return;
    }

    // Set loading state
    emit(state.copyWith(isLoading: true, error: null));

    try {
      // Simulate API call delay
      await Future.delayed(const Duration(milliseconds: 500));

      // Generate new items (10 per page)
      final newItems = _generateItems(state.page + 1);
      
      // Check if we've reached the limit (100 items = 10 pages)
      final newPage = state.page + 1;
      final hasMore = newPage < 10;

      emit(
        state.copyWith(
          items: [...state.items, ...newItems],
          isLoading: false,
          hasMore: hasMore,
          page: newPage,
        ),
      );
    } catch (e) {
      emit(
        state.copyWith(
          isLoading: false,
          error: e.toString(),
        ),
      );
    }
  }

  List<Todo> _generateItems(int page) {
    final items = <Todo>[];
    final startIndex = (page - 1) * 10;
    
    for (int i = 0; i < 10; i++) {
      final index = startIndex + i + 1;
      items.add(
        Todo(
          id: 'infinite_$index',
          title: 'Todo Item $index',
          completed: false,
        ),
      );
    }
    
    return items;
  }

  void reset() {
    emit(
      const InfiniteScrollState(
        items: [],
        isLoading: false,
        hasMore: true,
        page: 0,
        throttleCount: 0,
      ),
    );
  }
}

UI

import 'package:flutter/material.dart';
import 'package:shard/shard.dart';
import '../shard/infinite_scroll_shard.dart';
import '../models/infinite_scroll_state.dart';
import '../widgets/todo_item.dart';

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

  @override
  State<InfiniteScrollScreen> createState() => _InfiniteScrollScreenState();
}

class _InfiniteScrollScreenState extends State<InfiniteScrollScreen> {
  late final ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _scrollController.addListener(_onScroll);
    
    // Load initial data
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<InfiniteScrollShard>().loadMore();
    });
  }

  @override
  void dispose() {
    _scrollController.removeListener(_onScroll);
    _scrollController.dispose();
    super.dispose();
  }

  void _onScroll() {
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent * 0.8) {
      // Trigger loadMore when scrolled to 80%
      context.read<InfiniteScrollShard>().loadMore();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Infinite Scroll (Throttle Demo)'),
        actions: [
          // Show throttle count
          ShardSelector<InfiniteScrollShard, InfiniteScrollState, int>(
            selector: (state) => state.throttleCount,
            builder: (context, throttleCount) {
              return Padding(
                padding: const EdgeInsets.all(16.0),
                child: Row(
                  children: [
                    Icon(
                      Icons.speed,
                      size: 20,
                      color: throttleCount > 0 ? Colors.orange : Colors.grey,
                    ),
                    const SizedBox(width: 4),
                    Text(
                      'Throttled: $throttleCount',
                      style: Theme.of(context).textTheme.bodyMedium,
                    ),
                  ],
                ),
              );
            },
          ),
          // Reset button
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () {
              final shard = context.read<InfiniteScrollShard>();
              shard.reset();
              _scrollController.jumpTo(0);
              shard.loadMore();
            },
            tooltip: 'Reset',
          ),
        ],
      ),
      body: SafeArea(
        child: ShardBuilder<InfiniteScrollShard, InfiniteScrollState>(
          builder: (context, state) {
            if (state.items.isEmpty && state.isLoading) {
              return const Center(
                child: CircularProgressIndicator(),
              );
            }

            if (state.items.isEmpty && state.error != null) {
              return Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Icon(Icons.error_outline, size: 64, color: Colors.red),
                    const SizedBox(height: 16),
                    Text('Error: ${state.error}'),
                    const SizedBox(height: 16),
                    ElevatedButton(
                      onPressed: () => context.read<InfiniteScrollShard>().loadMore(),
                      child: const Text('Retry'),
                    ),
                  ],
                ),
              );
            }

            return Column(
              children: [
                // Stats bar
                Container(
                  padding: const EdgeInsets.all(12),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceAround,
                    children: [
                      Text('Items: ${state.items.length}'),
                      Text('Page: ${state.page}'),
                      Text('Has More: ${state.hasMore ? 'Yes' : 'No'}'),
                    ],
                  ),
                ),
                // List
                Expanded(
                  child: ListView.builder(
                    controller: _scrollController,
                    itemCount: state.items.length + (state.hasMore ? 1 : 0),
                    itemBuilder: (context, index) {
                      if (index >= state.items.length) {
                        // Loading indicator at the bottom
                        return Padding(
                          padding: const EdgeInsets.all(16.0),
                          child: Center(
                            child: state.isLoading
                                ? const CircularProgressIndicator()
                                : const SizedBox.shrink(),
                          ),
                        );
                      }

                      final todo = state.items[index];
                      return TodoItem(
                        todo: todo,
                        onToggle: () {},
                        onDelete: () {},
                      );
                    },
                  ),
                ),
                // Error banner
                if (state.error != null)
                  Container(
                    width: double.infinity,
                    padding: const EdgeInsets.all(12),
                    color: Colors.red.shade100,
                    child: Row(
                      children: [
                        Icon(Icons.error, color: Colors.red.shade700),
                        const SizedBox(width: 8),
                        Expanded(
                          child: Text(state.error!),
                        ),
                        TextButton(
                          onPressed: () => context.read<InfiniteScrollShard>().loadMore(),
                          child: const Text('Retry'),
                        ),
                      ],
                    ),
                  ),
              ],
            );
          },
        ),
      ),
    );
  }
}

Key Features

  1. Throttle: Prevents excessive API calls when scrolling quickly
  2. Loading States: Shows loading indicators appropriately
  3. Error Handling: Displays errors and allows retry
  4. Throttle Counter: Visual feedback when throttle is active
  5. Reset Functionality: Allows resetting the list

How It Works

  1. User scrolls to 80% of the list
  2. loadMore() is called
  3. Throttle prevents execution if called within 1 second
  4. If throttled, throttle count is incremented
  5. If not throttled, data is loaded
  6. List updates with new items

Next Steps

Check out Best Practices for more tips and patterns.