DEV Community

Cover image for Implementando Infinite Scroll Pagination com Riverpod
Leticya Sheyla
Leticya Sheyla

Posted on • Updated on

Implementando Infinite Scroll Pagination com Riverpod

Nesse código usaremos os pacotes flutter_riverpod e infinite_scroll_pagination.

Recentemente, comecei a usar riverpod como meu gerenciador de estado.

No meu projeto eu utilizei o pacote infinite_scroll_pagination pra trabalhar com listas paginadas, e, para implementar cache na minha lista, eu precisava adicionar flutter_riverpod.

Antes de tudo começaremos agrupando todo o aplicativo em um ProviderScope:

class MyApp extends StatelessWidget {
  final Widget child;


  MyApp({super.key, required this.child});

  @override
  Widget build(BuildContext context) {

    return ProviderScope(
      child: FluentProvider(
        child: MaterialApp(
          title: 'Flutter Demo',
          theme: theme,
          home: child,
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Repository

Criaremos a classe repositório e criaremos um Provider pra ela:

final usersRepositoryProvider = Provider((ref) => UsersRepository());
Enter fullscreen mode Exit fullscreen mode
class UsersRepository {
  final http.Client client = http.Client();
  final apiBaseUri = Uri.parse("https://api.slingacademy.com/");

  Future<Result<UsersPagedList<UserModel>>> fetchUsers(
    int pageNumber,
  ) async {
    try {
      const int limit = 10;
      final String offset = (pageNumber * limit).toString();
      final response = await client.get(
        apiBaseUri.replace(
          path: "v1/sample-data/users",
          queryParameters: {
            "offset": offset,
          },
        ),
      );

      if (response.statusCode != 200) {
        switch (response.statusCode) {
          case 404:
            return Result.error(
              'Recurso não encontrado. Verifique a URL ou tente novamente mais tarde',
            );
          case 500:
            return Result.error('Erro Interno do Servidor.');
          default:
            return Result.error('Erro Http');
        }
      }

      final jsonListResponse =
          jsonDecode(response.body) as Map<String, Object?>;

      final modelPagedList = UsersPagedList.fromJson(
        jsonListResponse,
        UserModel.fromJson,
      );

      return Result.value(modelPagedList);
    } on ClientException {
      return Result.error("Erro de conexão");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode



Pagination Controller

Vamos criar a classe RiverpodPaginationController. Começamos criando nosso pagingController assim como mostra na implementação da infinite scroll pagination. Usamos a função addPageRequestListener e chamamos nossa função de request no construtor.

class RiverpodPaginationController<T> {
  final PagingController<int, T> pagingController =
  PagingController(firstPageKey: 0);

  RiverpodPaginationController(){
    pagingController.addPageRequestListener(onPageRequest);
  }

  void onPageRequest(pageKey){
    // request data here
  }
}
Enter fullscreen mode Exit fullscreen mode

Para que não esqueçamos de usar dispose no nosso controller, criaremos a classe abstrata ViewController e vamos fazer nossa pagination controller extender dela.

import 'package:flutter/material.dart';

abstract class ViewController<TState extends State> implements SimpleController {
  final TState state;

  ViewController(this.state);
}

abstract interface class SimpleController{
  void dispose();
}
Enter fullscreen mode Exit fullscreen mode

Agora implementamos a função dispose e damos dispose em nossa pagingController:

@override
  void dispose() {
    pagingController.dispose();
  }
Enter fullscreen mode Exit fullscreen mode

Precisaremos passar dois parâmetros para nossa classe: o ref (esse objeto nos ajuda a interagir com providers), e o nosso provider que busca os dados.

class RiverpodPaginationController extends ViewController {
  final WidgetRef ref;

    ProviderListenable<AsyncValue<UsersPagedList<UserModel>>> Function(
    int pageKey,
  ) provider;

final PagingController<int, UserModel> pagingController =
      PagingController(firstPageKey: 0);

  RiverpodPaginationController(super.state, {
    required this.ref,
    required this.provider,
  }) {
    pagingController.addPageRequestListener(onPageRequest);
  }
Enter fullscreen mode Exit fullscreen mode

Vamos criar uma lista de ProviderListenable e a cada chamada de página adicionamos um novo ProviderListenable, usando a função onPageRequest que é chamada na nossa addPageRequestListener.


final List<ProviderSubscription<AsyncValue<UsersPagedList<UserModel>>>>
      subs = [];
Enter fullscreen mode Exit fullscreen mode

Usaremos o ref.listenManual onde passaremos o provider e uma função que vai ser executada toda vez que o valor do provider mudar, chamaremos essa função de handleState. Não esquecendo de passar fireImmediately como true.

void onPageRequest(int pageKey) {
    subs.add(
      ref.listenManual(
        provider(pageKey),
            (previous, next) {
          handleState(
            pageInfo: next,
            pageKey: pageKey,
            previousState: previous,
          );
        },
        fireImmediately: true,
      ),
    );
  }
Enter fullscreen mode Exit fullscreen mode

Na nossa handleState usaremos a função .when para lidar com o valor AsyncValue e seguimos a lógica de paginação do infiniteScrollPagination:

void handleState({
    required AsyncValue<UsersPagedList<UserModel>>? previousState,
    required AsyncValue<UsersPagedList<UserModel>> pageList,
    required int pageKey,
  }) async {
    await pageList.when(
      skipLoadingOnRefresh: true,
      data: (pagedListData) async {
        final List<UserModel> usersList = pagedListData.users;

        final isLastPage = usersList.length < pagedListData.limit;

        usersList.forEach((element) async {
          final coverImageUrl = element.profile_picture;

          await DefaultCacheManager().downloadFile(coverImageUrl);
        });

        if (isLastPage) {
          pagingController.appendLastPage(usersList);
        } else {
          final nextPageKey = pageKey + 1;
          pagingController.appendPage(usersList, nextPageKey);
        }
      },
      error: (error, stack) {
        pagingController.error = error;
      },
      loading: () {
        print("Loading...");
      },
    );
  }
Enter fullscreen mode Exit fullscreen mode


View

My paginated list with riverpod

Vamos criar nosso provider que busca os dados. Será um FutureProvidercom os modificadores .familly (obtém um único provider com base em um parâmetro externo) e autoDispose(para destruir o estado de um provider quando ele não está mais sendo utilizado).

Vamos usar ref.keepAlive na primeira página pra que esses dados sejam guardados.

Assim como a documentação do riverpod nos mostra, podemos implementar um método de extensão para manter o estado ativo durante um periodo de tempo:

extension CacheForExtension on AutoDisposeRef<Object?> {
  /// Keeps the provider alive for [duration].
  void cacheFor(Duration duration) {
    // Immediately prevent the state from getting destroyed.
    final link = keepAlive();
    // After duration has elapsed, we re-enable automatic disposal.
    final timer = Timer(duration, link.close);

    // Optional: when the provider is recomputed (such as with ref.watch),
    // we cancel the pending timer.
    onDispose(timer.cancel);
  }
}
Enter fullscreen mode Exit fullscreen mode

Então nosso provider fica assim:

final usersProvider = FutureProvider.autoDispose
    .family<UsersPagedList<UserModel>, int>((ref, pageNumber) async {
  /// Keeps the state alive for 10 seconds

  final users = await ref.read(usersRepositoryProvider).fetchUsers(pageNumber);

  if (pageNumber == 0) {
    ref.keepAlive();
  } else {
    ref.cacheFor(const Duration(seconds: 10));

    ref.onDispose(() {
      print('Dispose');
    });
  }

  return users.asFuture;
});
Enter fullscreen mode Exit fullscreen mode

Na view usaremos o ConsumerStatefulWidget (que é equivalente ao StateFullWidget, com a diferença que no State temos acesso ao objeto ref) do riverpod, e a classe RiverpodPaginationController vai ser instanciada no initState:

class UsersView extends ConsumerStatefulWidget {
  const UsersView({super.key});

  @override
  ConsumerState<UsersView> createState() => _UsersViewState();
}

class _UsersViewState extends ConsumerState<UsersView> {
  late final RiverpodPaginationController paginationController;

  @override
  void initState() {
    super.initState();
    paginationController = RiverpodPaginationController(
      this,
      ref: ref,
      provider: (pageKey) => usersProvider(pageKey),
    );
  }

  @override
  Widget build(BuildContext context) {
    return FluentScaffold(
      extendBodyBehindAppBar: true,
      appBar: AppBar(
        title: const Text(
          "Users List",
          style: TextStyle(
            color: Colors.white,
            fontWeight: FontWeight.w500,
          ),
        ),
        actions: const [
          Padding(
            padding: EdgeInsets.only(right: 16),
            child: Icon(Icons.info),
          ),
          Padding(
            padding: EdgeInsets.only(right: 16),
            child: Icon(Icons.add_circle),
          ),
        ],
        foregroundColor: Colors.white70,
        backgroundColor: Colors.transparent,
      ),
      body: Container(
        padding: const EdgeInsets.only(top: 18),
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Color(0XFF273754),
              Color(0XFF4b6696),
            ],
          ),
        ),
        child: SafeArea(
          child: Column(
            children: [
              SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                child: Padding(
                  padding: const EdgeInsets.only(bottom: 30, top: 16),
                  child: Row(
                    children: [
                      const SizedBox(width: 16),
                      Container(
                        decoration: BoxDecoration(
                            color: Colors.cyan,
                            borderRadius: BorderRadius.circular(8)),
                        padding: const EdgeInsets.all(10),
                        child: const Text(
                          "Lorem Ipsum",
                          style: TextStyle(
                            fontSize: 18,
                          ),
                        ),
                      ),
                      const SizedBox(width: 16),
                      Container(
                        decoration: BoxDecoration(
                          color: Colors.cyan,
                          borderRadius: BorderRadius.circular(8),
                        ),
                        padding: const EdgeInsets.all(10),
                        child: const Text(
                          "Lorem Ipsum",
                          style: TextStyle(
                            fontSize: 18,
                          ),
                        ),
                      ),
                      const SizedBox(width: 16),
                      Container(
                        decoration: BoxDecoration(
                          color: Colors.cyan,
                          borderRadius: BorderRadius.circular(8),
                        ),
                        padding: const EdgeInsets.all(10),
                        child: const Text(
                          "Lorem Ipsum",
                          style: TextStyle(
                            fontSize: 18,
                          ),
                        ),
                      )
                    ],
                  ),
                ),
              ),
              Expanded(
                child: PagedListView(
                  pagingController: paginationController.pagingController,
                  padding: const EdgeInsets.symmetric(horizontal: 16),
                  builderDelegate: PagedChildBuilderDelegate<UserModel>(
                    animateTransitions: false,
                    itemBuilder: (context, item, index) {
                      return Container(
                        decoration: BoxDecoration(
                          border: Border(
                            bottom: BorderSide(
                              color: const Color(0XFF57859b).withOpacity(0.7),
                              width: 0.5,
                            ),
                          ),
                        ),
                        padding: const EdgeInsets.symmetric(vertical: 18),
                        child: Row(
                          children: [
                            FluentContainer(
                              cornerRadius: FluentCornerRadius.circle,
                              shadow: FluentThemeDataModel.of(context)
                                  .fluentShadowTheme
                                  ?.shadow8,
                              width: 60,
                              height: 60,
                              child: Image.network(
                                item.profile_picture,
                                fit: BoxFit.cover,
                              ),
                            ),
                            const SizedBox(width: 20),
                            Expanded(
                              child: Container(
                                child: Column(
                                  crossAxisAlignment: CrossAxisAlignment.start,
                                  children: [
                                    Text(
                                      item.first_name + item.last_name,
                                      textAlign: TextAlign.start,
                                      style: const TextStyle(
                                        fontWeight: FontWeight.w600,
                                        color: Colors.white,
                                        fontSize: 18,
                                      ),
                                    ),
                                    const SizedBox(
                                      height: 4,
                                    ),
                                    Text(
                                      item.email,
                                      textAlign: TextAlign.center,
                                      style: const TextStyle(
                                        fontSize: 13,
                                        color: Colors.white,
                                      ),
                                    ),
                                    Row(
                                      children: [
                                        Icon(
                                          FluentIcons.location_12_filled,
                                          size: FluentSize.size120.value,
                                          color: Colors.white60,
                                        ),
                                        const SizedBox(
                                          width: 4,
                                        ),
                                        Text(
                                          item.city,
                                          style: const TextStyle(
                                            fontSize: 13,
                                            color: Colors.white60,
                                          ),
                                        ),
                                      ],
                                    ),
                                  ],
                                ),
                              ),
                            ),
                          ],
                        ),
                      );
                    },
                    firstPageErrorIndicatorBuilder: (context) => Center(
                      child: FilledButton(
                        onPressed: () {
                          print("Should be refreshing");
                          paginationController.pagingController.refresh();
                        },
                        child: const Text(
                          "Tente Novamente",
                        ),
                      ),
                    ),
                    newPageErrorIndicatorBuilder: (context) => Center(
                      child: FilledButton(
                        onPressed: () {
                          print("Should be refreshing");
                          paginationController.pagingController.refresh();
                        },
                        child: const Text(
                          "Tente Novamente",
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Model

Essas são minhas Models:

  • ‎ UserModel:
class UserModel {
  final int id;
  final String city;
  final String email;
  final String last_name;
  final String first_name;
  final String profile_picture;

  UserModel({
    required this.id,
    required this.city,
    required this.email,
    required this.last_name,
    required this.first_name,
    required this.profile_picture,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json["id"] as int,
      city: json["city"].toString(),
      email: json["email"].toString(),
      last_name: json["last_name"].toString(),
      first_name: json["first_name"].toString(),
      profile_picture: json["profile_picture"].toString(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
  • UsersPagedList:
class UsersPagedList<T> {
  final bool success;
  final String message;
  final int total_users;
  final int offset;
  final int limit;
  final List<T> users;

  UsersPagedList.raw({
    required this.success,
    required this.total_users,
    required this.message,
    required this.offset,
    required this.limit,
    required this.users,
  });

  factory UsersPagedList.fromJson(
    Map<String, Object?> jsonObject,
    FromJsonObjectConstructor<T> constructor,
  ) {
    return UsersPagedList.raw(
      success: jsonObject["success"]! as bool,
      total_users: jsonObject["total_users"]! as int,
      message: jsonObject["message"].toString(),
      offset: jsonObject["offset"]! as int,
      limit: jsonObject["limit"]! as int,
      users: (jsonObject["users"]! as List<dynamic>)
          .cast<Map<String, Object?>>()
          .map((jsonObject) {
        return constructor(jsonObject);
      }).toList(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)