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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.16 13:50:26