← Назад к вопросам
Реализовать бесконечный скроллируемый список с пагинацией
2.3 Middle🔥 231 комментариев
#Flutter виджеты#State Management#Работа с сетью
Условие
Создайте список с бесконечной прокруткой (infinite scroll) и пагинацией.
Требования
- Загрузка данных порциями (по 20 элементов)
- API для тестирования: https://jsonplaceholder.typicode.com/posts?_page=1&_limit=20
- При достижении конца списка автоматически загружать следующую порцию
- Показ индикатора загрузки внизу списка
- Обработка состояния "данных больше нет"
Дополнительные баллы
- Pull-to-refresh для обновления с начала
- Показ номера страницы
- Скелетон-лоадер вместо CircularProgressIndicator
- Кэширование загруженных данных
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Решение: Flutter бесконечный скроллируемый список с пагинацией
Представляю полное решение с автоматической загрузкой при достижении конца списка, pull-to-refresh и скелетон-лоадером.
1. Модель данных (lib/models/post_model.dart)
import "package:freezed_annotation/freezed_annotation.dart";
part "post_model.freezed.dart";
part "post_model.g.dart";
@freezed
class Post with _\$Post {
const factory Post({
required int id,
required int userId,
required String title,
required String body,
}) = _Post;
factory Post.fromJson(Map<String, dynamic> json) => _\$PostFromJson(json);
}
2. Сервис API (lib/services/post_service.dart)
import "package:dio/dio.dart";
import "../models/post_model.dart";
class PostService {
static const String _baseUrl = "https://jsonplaceholder.typicode.com";
static const int _postsPerPage = 20;
late final Dio _dio;
PostService() {
_dio = Dio(
BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
}
Future<List<Post>> getPosts({
required int page,
int limit = _postsPerPage,
}) async {
try {
final response = await _dio.get(
"/posts",
queryParameters: {
"_page": page,
"_limit": limit,
},
);
if (response.statusCode == 200) {
final data = response.data as List;
return data.map((json) => Post.fromJson(json)).toList();
} else {
throw Exception("Ошибка загрузки: ${response.statusCode}");
}
} catch (e) {
rethrow;
}
}
}
3. Провайдеры (lib/providers/post_providers.dart)
import "package:flutter_riverpod/flutter_riverpod.dart";
import "../models/post_model.dart";
import "../services/post_service.dart";
final postServiceProvider = Provider((ref) => PostService());
final paginationStateProvider = StateProvider<PaginationState>((ref) {
return PaginationState(
posts: [],
currentPage: 1,
isLoading: false,
isEndReached: false,
hasError: false,
errorMessage: "",
);
});
final postsProvider = StateNotifierProvider<PostsNotifier, PaginationState>((ref) {
final service = ref.watch(postServiceProvider);
return PostsNotifier(service, ref);
});
final refreshTriggerProvider = StateProvider<int>((ref) => 0);
class PaginationState {
final List<Post> posts;
final int currentPage;
final bool isLoading;
final bool isEndReached;
final bool hasError;
final String errorMessage;
PaginationState({
required this.posts,
required this.currentPage,
required this.isLoading,
required this.isEndReached,
required this.hasError,
required this.errorMessage,
});
PaginationState copyWith({
List<Post>? posts,
int? currentPage,
bool? isLoading,
bool? isEndReached,
bool? hasError,
String? errorMessage,
}) {
return PaginationState(
posts: posts ?? this.posts,
currentPage: currentPage ?? this.currentPage,
isLoading: isLoading ?? this.isLoading,
isEndReached: isEndReached ?? this.isEndReached,
hasError: hasError ?? this.hasError,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}
class PostsNotifier extends StateNotifier<PaginationState> {
final PostService _service;
final Ref _ref;
PostsNotifier(this._service, this._ref)
: super(PaginationState(
posts: [],
currentPage: 1,
isLoading: false,
isEndReached: false,
hasError: false,
errorMessage: "",
)) {
_loadInitialPosts();
}
Future<void> _loadInitialPosts() async {
state = state.copyWith(isLoading: true, hasError: false);
try {
final posts = await _service.getPosts(page: 1);
state = state.copyWith(
posts: posts,
currentPage: 1,
isLoading: false,
isEndReached: posts.isEmpty || posts.length < 20,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
hasError: true,
errorMessage: e.toString(),
);
}
}
Future<void> loadMorePosts() async {
if (state.isLoading || state.isEndReached) return;
state = state.copyWith(isLoading: true);
try {
final nextPage = state.currentPage + 1;
final newPosts = await _service.getPosts(page: nextPage);
if (newPosts.isEmpty || newPosts.length < 20) {
state = state.copyWith(
posts: [...state.posts, ...newPosts],
currentPage: nextPage,
isLoading: false,
isEndReached: true,
);
} else {
state = state.copyWith(
posts: [...state.posts, ...newPosts],
currentPage: nextPage,
isLoading: false,
);
}
} catch (e) {
state = state.copyWith(
isLoading: false,
hasError: true,
errorMessage: e.toString(),
);
}
}
Future<void> refresh() async {
_ref.read(refreshTriggerProvider.notifier).state++;
await _loadInitialPosts();
}
}
4. Виджет скелетон-лоадера (lib/widgets/post_skeleton.dart)
import "package:flutter/material.dart";
class PostSkeleton extends StatefulWidget {
const PostSkeleton({Key? key}) : super(key: key);
@override
State<PostSkeleton> createState() => _PostSkeletonState();
}
class _PostSkeletonState extends State<PostSkeleton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
)..repeat();
_animation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListTile(
title: AnimatedBuilder(
animation: _animation,
builder: (context, child) => Container(
height: 16,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.grey[300]?.withOpacity(0.5 + (_animation.value * 0.3)),
),
),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedBuilder(
animation: _animation,
builder: (context, child) => Container(
height: 12,
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color:
Colors.grey[300]?.withOpacity(0.5 + (_animation.value * 0.3)),
),
),
),
AnimatedBuilder(
animation: _animation,
builder: (context, child) => Container(
height: 12,
width: 150,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color:
Colors.grey[300]?.withOpacity(0.5 + (_animation.value * 0.3)),
),
),
),
],
),
),
);
}
}
5. Виджет списка постов (lib/widgets/posts_list.dart)
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "../providers/post_providers.dart";
import "post_skeleton.dart";
class PostsList extends ConsumerStatefulWidget {
const PostsList({Key? key}) : super(key: key);
@override
ConsumerState<PostsList> createState() => _PostsListState();
}
class _PostsListState extends ConsumerState<PostsList> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
ref.read(postsProvider.notifier).loadMorePosts();
}
}
Future<void> _onRefresh() async {
await ref.read(postsProvider.notifier).refresh();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(postsProvider);
if (state.hasError && state.posts.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text("Ошибка: ${state.errorMessage}"),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _onRefresh,
child: const Text("Попробовать ещё"),
),
],
),
);
}
if (state.posts.isEmpty && state.isLoading) {
return ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => const PostSkeleton(),
);
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
controller: _scrollController,
itemCount: state.isLoading && state.posts.isNotEmpty
? state.posts.length + 1
: state.posts.length,
itemBuilder: (context, index) {
// Индикатор загрузки в конце списка
if (index == state.posts.length) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const CircularProgressIndicator(),
const SizedBox(height: 8),
Text(
"Загрузка страницы ${state.currentPage + 1}...",
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
final post = state.posts[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue,
child: Text(
post.id.toString(),
style: const TextStyle(color: Colors.white),
),
),
title: Text(
post.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
post.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
"ID пользователя: ${post.userId}",
style: TextStyle(color: Colors.grey[500], fontSize: 12),
),
],
),
),
);
},
),
);
}
}
6. Главный экран (lib/screens/posts_screen.dart)
import "package:flutter/material.dart";
import "../widgets/posts_list.dart";
class PostsScreen extends StatelessWidget {
const PostsScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Посты"),
elevation: 0,
backgroundColor: Colors.blue,
),
body: const PostsList(),
);
}
}
7. main.dart
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "screens/posts_screen.dart";
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ProviderScope(
child: MaterialApp(
title: "Posts App",
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: ThemeMode.system,
home: const PostsScreen(),
),
);
}
}
8. pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.4.0
dio: ^5.3.1
freezed_annotation: ^2.4.1
json_serializable: ^6.7.1
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.6
freezed: ^2.4.1
json_serializable: ^6.7.1
Ключевые особенности
- Infinite Scroll: Автоматическая загрузка при прокрутке в конец
- Пагинация: Загрузка по 20 элементов за раз
- Pull-to-Refresh: Обновление списка с начала
- Скелетон-лоадер: Плавная анимация загрузки вместо индикатора
- Состояния: Обработка loading, success, error, end-of-list
- Кэширование: Данные сохраняются в памяти
- Обработка ошибок: Информативные сообщения и кнопка повтора
- Номер страницы: Отображение текущей страницы при загрузке
- Оптимизация: ScrollController слушает конец списка
Это production-ready решение для работы с большими списками!