You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter中基于TMDB API实现类Android Paging3分页加载的方法咨询

Flutter + Riverpod 实现TMDB影视列表分页加载

我正在用Flutter开发基于TMDB API的影视应用,目前启动时会加载第一页(约20部影片)的数据,想要实现类似Android Paging3的分页加载功能,通过递增页码获取更多影片数据,用Riverpod做状态管理,现有列表部分代码如下:

Expanded(
  child: Consumer(
    builder: (context,ref,_){
      var popularMovies = ref.watch(MovieController.popularMoviesProvider(counter)).value;
      if(popularMovies != null){
        return ListView.builder(
          controller: _scrollController,
          shrinkWrap: true,
          scrollDirection: Axis.vertical,
          itemCount: popularMovies.results?.length,
          itemBuilder: (context,index){
            var finalResult = popularMovies.results!;
            return GestureDetector(
              onTap: (){
                Get.to(() => const DetailsScreen(),
                    arguments: [
                      {"movieId" : finalResult[index].id}
                    ]);
              },
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Row(
                  children: [
                    Material(
                      elevation: 4,
                      shape:  RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
                      clipBehavior: Clip.antiAlias,
                      child: ClipRRect(
                        borderRadius : BorderRadius.circular(8),
                        child: Material(
                          child: Image.network("http://image.tmdb.org/t/p/w500/${finalResult[index].backdropPath}",
                            height: 170,width : 120 ,fit: BoxFit.cover,),
                        ),
                      ),
                    ),
                    Expanded(
                      child: Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                          children: [
                            SizedBox(
                              width : 250,
                              child: Text(finalResult[index].title!,style: const TextStyle(
                                  fontSize: 18,
                                  fontWeight: FontWeight.bold,
                                  fontFamily: 'mulish_bold'
                              ),maxLines: 2,),
                            ),
                            const SizedBox(height: 10,),
                            SizedBox(
                              width: 150,
                              child: Row(
                                children: [
                                  Image.asset("assets/icons/rating.png",height: 20,width: 20,),
                                  Expanded(child: Text("${finalResult[index].voteAverage}/10 Imdb",style: const TextStyle(fontFamily: 'mulish_regular',
                                      color: Color(0xFF9C9C9C)),))
                                ],
                              ),

                            ),
                            const SizedBox(height: 10,),
                            Consumer(
                              builder: (context,ref,_){
                                var genres = ref.watch(MovieController.moviesGenreProvider).value;
                                if(genres != null){
                                  return showGenres(index,genres.genres,popularMovies);
                                } else {
                                  return const Center(
                                    child: SizedBox(
                                      height: 2,
                                      width: 100,
                                      child: LinearProgressIndicator(
                                        color: Colors.green,
                                      ),
                                    ),
                                  );
                                }
                              },
                            ),
                            const SizedBox(height: 10,),
                            Card(
                              shape: RoundedRectangleBorder(
                                  borderRadius: BorderRadius.circular(8)
                              ),
                              color: Colors.green.shade400,
                              child :  Padding(
                                padding: const EdgeInsets.all(4.0),
                                child: Text(finalResult[index].releaseDate!,style: const TextStyle(fontFamily: 'mulish_regular',
                                    color: Colors.white,fontSize: 13),),
                              ),
                            )
                          ],
                        ),
                      ),
                    )
                  ],
                ),
              ),
            );
          },
        );
      }
      else {
        return const Center(
          child: CircularProgressIndicator(color: Colors.orange,),
        );
      }
    },
  ),
)

实现步骤

1. 改造Riverpod状态管理,维护分页状态

把原来的popularMoviesProvider改成支持数据累加的状态管理,用AsyncNotifier维护分页核心状态:当前页码、已加载影片列表、加载状态、是否已到最后一页。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/models/movie.dart'; // 你的影片模型类
import 'package:your_app/services/tmdb_service.dart'; // 你的TMDB API服务类

// 定义分页状态类
class MoviePaginationState {
  final int currentPage;
  final List<Movie> movies;
  final bool isLoading;
  final bool hasReachedMax;

  MoviePaginationState({
    required this.currentPage,
    required this.movies,
    required this.isLoading,
    required this.hasReachedMax,
  });

  // 复制方法,用于更新状态
  MoviePaginationState copyWith({
    int? currentPage,
    List<Movie>? movies,
    bool? isLoading,
    bool? hasReachedMax,
  }) {
    return MoviePaginationState(
      currentPage: currentPage ?? this.currentPage,
      movies: movies ?? this.movies,
      isLoading: isLoading ?? this.isLoading,
      hasReachedMax: hasReachedMax ?? this.hasReachedMax,
    );
  }
}

// 定义AsyncNotifier
class PopularMoviesNotifier extends AsyncNotifier<MoviePaginationState> {
  final TmdbService _tmdbService = TmdbService(); // 你的TMDB服务实例

  @override
  FutureOr<MoviePaginationState> build() async {
    // 初始化加载第一页数据
    final firstPageData = await _tmdbService.getPopularMovies(page: 1);
    return MoviePaginationState(
      currentPage: 1,
      movies: firstPageData.results ?? [],
      isLoading: false,
      hasReachedMax: false,
    );
  }

  // 加载下一页数据
  Future<void> loadNextPage() async {
    if (state.value?.isLoading == true || state.value?.hasReachedMax == true) {
      return; // 正在加载或已到底,直接返回
    }

    // 更新状态为加载中
    state = AsyncData(state.value!.copyWith(isLoading: true));

    try {
      final nextPage = state.value!.currentPage + 1;
      final nextPageData = await _tmdbService.getPopularMovies(page: nextPage);

      if (nextPageData.results?.isEmpty == true) {
        // 没有更多数据
        state = AsyncData(state.value!.copyWith(
          isLoading: false,
          hasReachedMax: true,
        ));
        return;
      }

      // 累加新数据到列表
      state = AsyncData(state.value!.copyWith(
        currentPage: nextPage,
        movies: [...state.value!.movies, ...nextPageData.results!],
        isLoading: false,
      ));
    } catch (e) {
      // 处理错误,可根据需求添加错误状态
      state = AsyncData(state.value!.copyWith(isLoading: false));
      rethrow;
    }
  }
}

// 定义Provider
final popularMoviesPaginationProvider = AsyncNotifierProvider<PopularMoviesNotifier, MoviePaginationState>(
  () => PopularMoviesNotifier(),
);

2. 监听滚动事件,触发分页加载

在页面State类中初始化ScrollController,添加滚动监听,当滚动接近底部时触发下一页加载:

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

  @override
  State<MovieListScreen> createState() => _MovieListScreenState();
}

class _MovieListScreenState extends State<MovieListScreen> {
  final _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
  }

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

  void _onScroll() {
    // 滚动到距离底部200像素时触发加载
    if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
      ref.read(popularMoviesPaginationProvider.notifier).loadNextPage();
    }
  }

  // ... 其他代码
}

3. 调整UI层,显示加载状态和完整列表

修改原Consumer部分,使用新的分页Provider,并在列表底部添加加载状态提示:

Expanded(
  child: Consumer(
    builder: (context, ref, _) {
      final paginationState = ref.watch(popularMoviesPaginationProvider);
      return paginationState.when(
        loading: () => const Center(child: CircularProgressIndicator(color: Colors.orange)),
        error: (error, stack) => Center(child: Text('加载失败: $error')),
        data: (state) {
          return ListView.builder(
            controller: _scrollController,
            shrinkWrap: true,
            scrollDirection: Axis.vertical,
            // 列表项数 = 已加载影片数 + 底部状态项(如果需要)
            itemCount: state.movies.length + (state.isLoading || state.hasReachedMax ? 1 : 0),
            itemBuilder: (context, index) {
              if (index == state.movies.length) {
                // 底部状态项
                if (state.hasReachedMax) {
                  return const Padding(
                    padding: EdgeInsets.symmetric(vertical: 16),
                    child: Center(child: Text('没有更多影片了')),
                  );
                } else {
                  return const Padding(
                    padding: EdgeInsets.symmetric(vertical: 16),
                    child: Center(child: CircularProgressIndicator(color: Colors.orange)),
                  );
                }
              }
              final movie = state.movies[index];
              return GestureDetector(
                onTap: () {
                  Get.to(() => const DetailsScreen(),
                      arguments: [{"movieId": movie.id}]);
                },
                child: Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Row(
                    children: [
                      Material(
                        elevation: 4,
                        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
                        clipBehavior: Clip.antiAlias,
                        child: ClipRRect(
                          borderRadius: BorderRadius.circular(8),
                          child: Image.network(
                            "http://image.tmdb.org/t/p/w500/${movie.backdropPath}",
                            height: 170,
                            width: 120,
                            fit: BoxFit.cover,
                          ),
                        ),
                      ),
                      Expanded(
                        child: Padding(
                          padding: const EdgeInsets.all(8.0),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                            children: [
                              SizedBox(
                                width: 250,
                                child: Text(
                                  movie.title!,
                                  style: const TextStyle(
                                    fontSize: 18,
                                    fontWeight: FontWeight.bold,
                                    fontFamily: 'mulish_bold',
                                  ),
                                  maxLines: 2,
                                ),
                              ),
                              const SizedBox(height: 10),
                              SizedBox(
                                width: 150,
                                child: Row(
                                  children: [
                                    Image.asset(
                                      "assets/icons/rating.png",
                                      height: 20,
                                      width: 20,
                                    ),
                                    Expanded(
                                      child: Text(
                                        "${movie.voteAverage}/10 Imdb",
                                        style: const TextStyle(
                                          fontFamily: 'mulish_regular',
                                          color: Color(0xFF9C9C9C),
                                        ),
                                      ),
                                    ),
                                  ],
                                ),
                              ),
                              const SizedBox(height: 10),
                              Consumer(
                                builder: (context, ref, _) {
                                  var genres = ref.watch(MovieController.moviesGenreProvider).value;
                                  if (genres != null) {
                                    return showGenres(index, genres.genres, state); // 适配新状态结构
                                  } else {
                                    return const Center(
                                      child: SizedBox(
                                        height: 2,
                                        width: 100,
                                        child: LinearProgressIndicator(color: Colors.green),
                                      ),
                                    );
                                  }
                                },
                              ),
                              const SizedBox(height: 10),
                              Card(
                                shape: RoundedRectangleBorder(
                                  borderRadius: BorderRadius.circular(8),
                                ),
                                color: Colors.green.shade400,
                                child: Padding(
                                  padding: const EdgeInsets.all(4.0),
                                  child: Text(
                                    movie.releaseDate!,
                                    style: const TextStyle(
                                      fontFamily: 'mulish_regular',
                                      color: Colors.white,
                                      fontSize: 13,
                                    ),
                                  ),
                                ),
                              ),
                            ],
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
              );
            },
          );
        },
      );
    },
  ),
)

注意事项

  • 确保你的TMDB API服务类中getPopularMovies方法支持传入page参数,请求对应页码的数据。
  • showGenres方法需要适配新的MoviePaginationState结构,调整参数获取影片类型信息。
  • 可根据需求添加错误重试逻辑,比如在加载失败时显示重试按钮。

内容的提问来源于stack exchange,提问作者Taki

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.20 09:24:27