This example demonstrates infinite scroll with:
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,
);
}
}
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,
),
);
}
}
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'),
),
],
),
),
],
);
},
),
),
);
}
}
loadMore() is calledCheck out Best Practices for more tips and patterns.