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

使用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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.03 16:06:07