← Назад к вопросам

Реализовать бесконечный скроллируемый список с пагинацией

2.3 Middle🔥 231 комментариев
#Flutter виджеты#State Management#Работа с сетью

Условие

Создайте список с бесконечной прокруткой (infinite scroll) и пагинацией.

Требования

  1. Загрузка данных порциями (по 20 элементов)
  2. API для тестирования: https://jsonplaceholder.typicode.com/posts?_page=1&_limit=20
  3. При достижении конца списка автоматически загружать следующую порцию
  4. Показ индикатора загрузки внизу списка
  5. Обработка состояния "данных больше нет"

Дополнительные баллы

  • 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

Ключевые особенности

  1. Infinite Scroll: Автоматическая загрузка при прокрутке в конец
  2. Пагинация: Загрузка по 20 элементов за раз
  3. Pull-to-Refresh: Обновление списка с начала
  4. Скелетон-лоадер: Плавная анимация загрузки вместо индикатора
  5. Состояния: Обработка loading, success, error, end-of-list
  6. Кэширование: Данные сохраняются в памяти
  7. Обработка ошибок: Информативные сообщения и кнопка повтора
  8. Номер страницы: Отображение текущей страницы при загрузке
  9. Оптимизация: ScrollController слушает конец списка

Это production-ready решение для работы с большими списками!

Реализовать бесконечный скроллируемый список с пагинацией | PrepBro