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
相关产品推荐
相关产品推荐

