使用Riverpod实现分页时列表刷新后滚动位置异常求助
Riverpod分页加载后页面跳回顶部问题排查与修复
我是Riverpod初学者,尝试在页面中实现分页功能。当下滑到底部时发起新的API请求获取下一页数据,新数据能成功加载,但页面会自动回到顶部,需要重新滚动才能查看新内容。
更新内容
页面代码
import 'package:auto_route/annotations.dart'; import 'package:des_cartes/src/core/logger/log_service.dart'; import 'package:des_cartes/src/features/boardgames/presentation/manager/boardgames.provider.dart'; import 'package:des_cartes/src/features/boardgames/presentation/widgets/boardgame_tile.widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @RoutePage(name: 'BoardgamesPage') class BoardgamesPage extends ConsumerStatefulWidget { const BoardgamesPage({super.key}); @override ConsumerState createState() => _BoardgamesPageState(); } class _BoardgamesPageState extends ConsumerState<BoardgamesPage> { late ScrollController scrollController; late GlobalKey scrollKey; @override void initState() { super.initState(); scrollController = ScrollController(); scrollKey = GlobalKey(); scrollController.addListener(() { if (scrollController.position.atEdge) { if (scrollController.position.pixels != 0) { ref.read(pageProvider.notifier).update((state) => state = state + 1); } } }); } @override Widget build(BuildContext context) { final boardgames = ref.watch(boardgamesListNotifierProvider); return Scaffold( appBar: AppBar( title: Center( child: Padding( padding: const EdgeInsets.all(8.0), child: Image.asset( "assets/logo_title_cropped.png", height: 45, ), )), backgroundColor: const Color.fromRGBO(171, 237, 216, 1.0), ), body: SafeArea( child: Column( children: [ TextField( decoration: const InputDecoration( border: OutlineInputBorder(), labelText: 'Search', ), onChanged: (value) { ref .read(searchGameProvider.notifier) .update((state) => state = value); }, ), Expanded( child: SingleChildScrollView( key: scrollKey, controller: scrollController, child: Column( children: [ Padding( padding: const EdgeInsets.all(8.0), child: Container( child: boardgames.when( data: (boardgames) => Center( child: Wrap( spacing: 10, runSpacing: 10, children: boardgames .map((boardgame) => BoardgameTile(boardgame: boardgame)) .toList()), ), error: (err, _) => Text("error: $err"), loading: () => const CircularProgressIndicator()), ), ) ], ), ), ), ], )), ); } }
Provider代码
import 'package:des_cartes/src/core/logger/log_service.dart'; import 'package:des_cartes/src/features/boardgames/data/data_sources/boardgames.datasource.dart'; import 'package:des_cartes/src/features/boardgames/data/repositories/boardgames.repository.dart'; import 'package:des_cartes/src/features/boardgames/domain/entities/boardgame.dart'; import 'package:des_cartes/src/features/boardgames/domain/entities/boardgame.sort_filter_config.dart'; import 'package:des_cartes/src/features/boardgames/domain/repositories/boardgames.repository.dart'; import 'package:des_cartes/src/features/boardgames/domain/use_cases/get_boardgames.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'boardgames.provider.g.dart'; @riverpod BoardgamesDatasource boardgameDatasource(BoardgameDatasourceRef ref) => BoardgamesDatasource(); @riverpod BoardgamesRepository boardgamesRepository(BoardgamesRepositoryRef ref) => BoardgamesRepositoryImpl( datasource: ref.watch(boardgameDatasourceProvider)); @riverpod class BoardgamesListNotifier extends _$BoardgamesListNotifier { @override Future<List<Boardgame>> build() async { return fetchBoardgames(); } Future<List<Boardgame>> fetchBoardgames() async { final searchGame = ref.watch(searchGameProvider); final page = ref.watch(pageProvider); final config = BoardgameSortFilterConfig( filter: searchGame, limit: 10, page: page, type: BoardgameType.boardgameexpansion); final boardgamesRepository = ref.read(boardgamesRepositoryProvider); final boardgames = await GetBoardgames(boardgamesRepository)(config); return boardgames.fold((failure) => [], (boardgames) { return [...?state.value, ...boardgames]; }); } } final searchGameProvider = StateProvider<String>((ref) => ''); final pageProvider = StateProvider<int>((ref) => 1);
优化后仍存在的异常
- 缓慢滑动且手指不离开屏幕时,能看到新数据,但列表会闪烁
- 用力滑动时,滚动视图会跳回顶部
问题根源与修复方案
1. 移除SingleChildScrollView的GlobalKey
手动设置的GlobalKey会在UI重建时强制重置滚动组件关联,导致滚动位置丢失。直接删除相关代码:
// 删除这两行初始化代码 // late GlobalKey scrollKey; // scrollKey = GlobalKey(); // 修改SingleChildScrollView,移除key参数 SingleChildScrollView( controller: scrollController, // key: scrollKey, 删掉此行 child: ... )
2. 优化滚动监听逻辑,防止重复触发请求
添加加载状态标记,避免快速滑动时多次触发分页请求:
class _BoardgamesPageState extends ConsumerState<BoardgamesPage> { late ScrollController scrollController; bool _isLoading = false; @override void initState() { super.initState(); scrollController = ScrollController(); scrollController.addListener(() { // 改为接近底部(90%位置)就触发,避免atEdge判断的误差 if (scrollController.position.pixels >= scrollController.position.maxScrollExtent * 0.9 && !_isLoading) { _isLoading = true; ref.read(boardgamesListNotifierProvider.notifier).loadNextPage().then((_) { _isLoading = false; }); } }); } @override void dispose() { scrollController.dispose(); super.dispose(); } }
3. 重构BoardgamesListNotifier的状态管理
当前build方法会在page变化时重复执行,导致状态累加混乱。改为手动触发分页加载的逻辑:
@riverpod class BoardgamesListNotifier extends _$BoardgamesListNotifier { @override Future<List<Boardgame>> build() async { return fetchBoardgames(page: 1, reset: true); } Future<List<Boardgame>> fetchBoardgames({required int page, required bool reset}) async { final searchGame = ref.watch(searchGameProvider); final config = BoardgameSortFilterConfig( filter: searchGame, limit: 10, page: page, type: BoardgameType.boardgameexpansion); final boardgamesRepository = ref.read(boardgamesRepositoryProvider); final result = await GetBoardgames(boardgamesRepository)(config); return result.fold((failure) => [], (newBoardgames) { if (reset) { return newBoardgames; } else { return [...?state.value, ...newBoardgames]; } }); } Future<void> loadNextPage() async { if (state.isLoading) return; final currentPage = ref.read(pageProvider); final nextPage = currentPage + 1; state = const AsyncValue.loading(); try { final newData = await fetchBoardgames(page: nextPage, reset: false); state = AsyncValue.data(newData); ref.read(pageProvider.notifier).state = nextPage; } catch (e, stack) { state = AsyncValue.error(e, stack); } } }
4. 搜索时重置分页数据
修改TextField的onChanged逻辑,确保搜索时重置分页和列表数据:
TextField( decoration: const InputDecoration( border: OutlineInputBorder(), labelText: 'Search', ), onChanged: (value) { ref.read(searchGameProvider.notifier).state = value; ref.read(pageProvider.notifier).state = 1; ref.read(boardgamesListNotifierProvider.notifier).fetchBoardgames(page: 1, reset: true); }, )
5. 替换布局为ListView.builder(可选)
SingleChildScrollView+Column的组合在数据量大时性能差,改用ListView.builder更适合分页场景:
Expanded( child: boardgames.when( data: (boardgames) => ListView.builder( controller: scrollController, itemCount: boardgames.length, itemBuilder: (context, index) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), child: BoardgameTile(boardgame: boardgames[index]), ); }, ), error: (err, _) => const Center(child: Text("加载出错")), loading: () => const Center(child: CircularProgressIndicator()), ), )
内容的提问来源于stack exchange,提问作者Aymeric Le Feyer
相关产品推荐
相关产品推荐

