Flutter:如何在ListView Builder中添加最后阅读记录功能
实现电子书最后阅读位置与书签功能(基于ListView Builder)
核心思路
要实现关闭应用后恢复阅读位置,需要持久化存储当前阅读的章节ID和滚动偏移量,再在页面初始化时读取并滚动到对应位置。书签入口本质是触发这个滚动逻辑的快捷方式。
步骤1:定义阅读位置数据结构
先创建一个简单的模型类,封装需要保存的核心信息:
class ReadingPosition { final String chapterId; // 章节唯一标识(如序号、自定义ID) final double scrollOffset; // ListView的滚动偏移量 final DateTime lastReadTime; // 可选:记录阅读时间,用于多书签排序 ReadingPosition({ required this.chapterId, required this.scrollOffset, required this.lastReadTime, }); // 转成Map方便持久化 Map<String, dynamic> toMap() { return { 'chapterId': chapterId, 'scrollOffset': scrollOffset, 'lastReadTime': lastReadTime.toIso8601String(), }; } // 从Map解析成对象 static ReadingPosition fromMap(Map<String, dynamic> map) { return ReadingPosition( chapterId: map['chapterId'], scrollOffset: map['scrollOffset'], lastReadTime: DateTime.parse(map['lastReadTime']), ); } }
步骤2:选择持久化方案
如果只需要保存最后阅读位置,用SharedPreferences足够轻量;如果要支持多章节书签,建议用Hive或SQFlite。这里先演示SharedPreferences的用法:
先添加依赖到pubspec.yaml:
dependencies: shared_preferences: ^2.2.2
封装读写工具类:
import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; class ReadingPositionStorage { static const _key = 'last_read_position'; // 保存阅读位置 static Future<void> savePosition(ReadingPosition position) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_key, json.encode(position.toMap())); } // 读取阅读位置 static Future<ReadingPosition?> getPosition() async { final prefs = await SharedPreferences.getInstance(); final jsonString = prefs.getString(_key); if (jsonString == null) return null; final map = json.decode(jsonString) as Map<String, dynamic>; return ReadingPosition.fromMap(map); } }
步骤3:监听ListView滚动并保存位置
在章节页面使用ScrollController监听滚动,在页面销毁或应用退后台时保存当前位置:
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class ChapterPage extends StatefulWidget { final String chapterId; final List<String> contentItems; // 章节内容的每一项(如段落) const ChapterPage({ super.key, required this.chapterId, required this.contentItems, }); @override State<ChapterPage> createState() => _ChapterPageState(); } class _ChapterPageState extends State<ChapterPage> with WidgetsBindingObserver { late ScrollController _scrollController; ReadingPosition? _lastPosition; @override void initState() { super.initState(); _scrollController = ScrollController(); WidgetsBinding.instance.addObserver(this); // 初始化时读取上次阅读位置 _loadLastPosition(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); // 页面销毁前保存当前位置 _saveCurrentPosition(); _scrollController.dispose(); super.dispose(); } // 监听应用生命周期,退后台时保存 @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused) { _saveCurrentPosition(); } } // 加载上次阅读位置并滚动 Future<void> _loadLastPosition() async { final position = await ReadingPositionStorage.getPosition(); if (position != null && position.chapterId == widget.chapterId) { setState(() => _lastPosition = position); // 延迟滚动,确保ListView已构建完成 WidgetsBinding.instance.addPostFrameCallback((_) { _scrollController.jumpTo(position.scrollOffset); }); } } // 保存当前阅读位置 Future<void> _saveCurrentPosition() async { final currentOffset = _scrollController.offset; final position = ReadingPosition( chapterId: widget.chapterId, scrollOffset: currentOffset, lastReadTime: DateTime.now(), ); await ReadingPositionStorage.savePosition(position); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('章节${widget.chapterId}'), // 书签入口按钮 actions: [ IconButton( icon: const Icon(Icons.bookmark), onPressed: () { if (_lastPosition != null && _lastPosition!.chapterId == widget.chapterId) { _scrollController.animateTo( _lastPosition!.scrollOffset, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } }, ), ], ), body: ListView.builder( controller: _scrollController, itemCount: widget.contentItems.length, itemBuilder: (context, index) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text(widget.contentItems[index]), ); }, ), ); } }
步骤4:跨章节书签跳转实现
从主页书签入口直接跳转到对应章节并滚动:
// 主页书签按钮点击逻辑 void _navigateToLastRead() async { final position = await ReadingPositionStorage.getPosition(); if (position != null) { Navigator.push( context, MaterialPageRoute( builder: (context) => ChapterPage( chapterId: position.chapterId, contentItems: _fetchChapterContent(position.chapterId), // 根据章节ID获取内容 ), ), ); } }
注意事项
- 避免频繁保存:不要在每次滚动时都保存,建议在页面销毁、应用退后台时执行,或用防抖(debounce)处理滚动事件。
- 大量内容优化:如果章节内容极多,用
itemIndex替代scrollOffset更可靠(布局变化时偏移量可能失效),此时可通过Scrollable.ensureVisible滚动到指定item。 - 多书签支持:若需保存多个章节书签,只需将存储结构改为
List<ReadingPosition>,在UI中展示书签列表,点击对应条目即可跳转。
内容的提问来源于stack exchange,提问作者Bilal Mukhtiar
相关产品推荐
相关产品推荐

