iOS: .txt 小说阅读器功能开发的 5 个老套路

4,368 阅读13分钟

本文介绍本地 .txt 小说阅读器功能开发的 5 个相关技术点。

网络 .txt 小说开发,则多了下载和缓存两步

一本书有什么,即书的数据结构

一本书有书名,有正文,有目录

手机书架上的书很多,需给书分配一个 id,去除重复

小说用户的常见操作有两种,当前阅读进度记录和书签列表

小说的主要模型 ReadModel

书的两个自然属性: ID 和目录

( 一本书有书名,这里与 ID 合并 )

书的两个用户操作属性,阅读记录和书签

class ReadModel: NSObject,NSCoding {

    /// 小说ID, 书名
    let bookID:String
    
    /// 目录, 章节列表
    // 书的正文,按照章节拆分,保存在 ChapterBriefModel 关联的 ReadChapterModel 中
    var chapterListModels = [ChapterBriefModel]()
    
    /// 当前阅读记录
    var recordModel:ReadRecordModel?
    
    /// 书签列表
    var markModels = [ReadMarkModel]()
    
    
}

小说的目录模型 ChapterBriefModel

class ChapterBriefModel{
    
    /// 章节ID    
    var id: Int!

    /// 小说ID
    var bookID:String!
    
    /// 章节名称
    var name:String!
}

有了目录,要阅读,需要正文

小说的章节模型

包含具体的阅读章节纯文本 content,和用来渲染呈现的富文本 fullContent

含有上一章和下一章的 ID,作为一个链表,用于连续阅读


class ReadChapterModel: NSObject,NSCoding {
    
    /// 小说ID
    let bookID: String
    
    /// 章节ID
    let id: Int
    
    /// 上一章ID
    var previousChapterID: Int?
    
    /// 下一章ID
    var nextChapterID: Int?
    
    /// 章节名称
    var name:String!
    
     /// 内容
    /// 此处 content 是经过排版好且双空格开头的内容。 
    var content:String!
    
    /// 可以渲染的富文本内容
    var fullContent:NSAttributedString!
    
    /// 本章有多少页
    var pageCount: Int = 0
    
    /// 分页数据, 
    // 一屏幕内容,对应一个 ReadPageModel
    var pageModels = [ReadPageModel]()
    
    /// 内容的排版属性
    private var attributes = [NSAttributedString.Key: Any]()
    
}

小说的章节模型 ReadChapterModel 通过 bookID 小说 ID 和 id 章节 ID,

与上面的目录模型 ChapterBriefModel 作关联,

有了 ChapterBriefModel ,拿关联信息,去解档,找出 ReadChapterModel

这样的好处是:

一本《三国演义》的 txt, 1.8 M, 有 120 章, 拆分成 120 个占内存的 ReadChapterModel,

占内存的 ReadChapterModel 需要时解档,不需要就释放,

阅读模型 ReadModel 持有的是,轻量级的目录模型 ChapterBriefModel

小说一屏幕内容,就是一页,一个 ReadPageModel

class ReadPageModel: NSObject,NSCoding {

    // MARK: 常用属性
    
    /// 当前页内容
    var content:NSAttributedString!
    
    /// 当前页范围,
    // (当前页的第一个字,是第多少个), (当前页有多少字)
    var pageRange:NSRange!
    
    /// 当前页的页码
    var page: Int = 0
    
    
    // MARK: 滚动模式相关
    
    //  滚动模式的排版
    /// 根据开头类型返回开头高度 
    var headTypeHeight:CGFloat = 0
    
    /// 当前内容 Size 
    var contentSize = CGSize.zero
    
    /// 当前内容头部类型
    private
    var headTypeIndex: Int = 0
    
    /// 当前内容头部类型 
    var headType: PageHeadType? {
        set{
            if let n = newValue{
                headTypeIndex = n.rawValue
            }
        }
        get{
            PageHeadType(rawValue: headTypeIndex)
        }
    }
}
一本书的数据结构确立后,进入功能开发

1,基础呈现:

网上下载了一本 《三国演义》,制作一个基本的阅读界面

.txt 小说 -> 小说代码模型 -> 用视图把小说呈现出来

1.1 模型解析

1.1.1 把资源路径,转化为正文

对文本编码

class func encode(url:URL) -> String {
        
        var content = ""
        
        if url.absoluteString.isEmpty { return content }
        
        // utf8
        content = encode(path: url, encoding: String.Encoding.utf8.rawValue)
        
        // 进制编码
        if content.isEmpty { content = encode(path: url, encoding: 0x80000632) }
        
        if content.isEmpty { content = encode(path: url, encoding: 0x80000631) }
        
        if content.isEmpty { content = "" }
        
        return content
    }
    
    class func encode(path url:URL, encoding:UInt) ->String {
        do{
            return try NSString(contentsOf: url, encoding: encoding) as String
        }
        catch{
            return ""
        }
    }
1.1.2 解析出所有的章节目录,不含正文的 ChapterBriefModel, 含正文的 ReadChapterModel

下面的代码,分为两部分,一个正则, 一个 for 循环

把正文作为一个字符串,正则拆分出所有的章节,

for 循环中,把拆除来的章节,映射为 ChapterBriefModel 和 ReadChapterModel

ReadChapterModel 归档持久化,调用时再解档

 /// 解析整本小说
    /// - Parameters:   - bookID: 小说ID   - content: 小说内容
    /// - Returns: 章节列表
    private class func parser(segments bookID:String, content:String) ->[ChapterBriefModel] {
        
        // 章节列表
        var chapterListModels = [ChapterBriefModel]()
        
        // 正则
        let parten = "第[0-9一二三四五六七八九十百千]*[章回].*"
        
        // 排版
        let content = ReadParserIMP.contentTypesetting(content: content)
        
        // 正则匹配结果
        var results = [NSTextCheckingResult]()
        
        // 开始匹配
        do{
            let regularExpression = try NSRegularExpression(pattern: parten, options: .caseInsensitive)
            
            results = regularExpression.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count))
            
        }catch{
            
            return chapterListModels
        }
        
        // 解析匹配结果
        
        
        guard results.isEmpty == false else {
            // ....
            return // ...
        }
        
            
        // 章节数量
        let count = results.count
        
        // 记录最后一个Range
        var lastRange:NSRange!
        
        // 记录最后一个章节对象C
        var lastChapterModel:ReadChapterModel?
        
        // 有前言
        var isHavePreface = true
        
        // 遍历
        for i in 0...count {
            
            // 章节数量分析:
            // count + 1  = 匹配到的章节数量 + 最后一个章节
            // 1 + count + 1  = 第一章前面的前言内容 + 匹配到的章节数量 + 最后一个章节
            Log("章节总数: \(count + 1)  当前正在解析: \(i + 1)")
            
            var range = NSMakeRange(0, 0)
            
            var location = 0
            
            if i < count {
                
                range = results[i].range
                
                location = range.location
            }
            
            // 章节内容
            let chapterModel = ReadChapterModel(id: i + isHavePreface.val, in: bookID)
            switch i {
            case 0:
                // 前言
                
                // 章节名
                chapterModel.name = "开始"
                
                // 内容
                chapterModel.content = content.substring(NSMakeRange(0, location))
                
                // 记录
                lastRange = range
                
                // 没有内容则不需要添加列表
                if chapterModel.content.isEmpty {
                    
                    isHavePreface = false
                    
                    continue
                }
            case count:
                // 结尾
                
                // 章节名
                chapterModel.name = content.substring(lastRange)
                
                // 内容(不包含章节名)
                chapterModel.content = content.substring(NSMakeRange(lastRange.rhs, content.count - lastRange.rhs))
            default:
                // 中间章节
                
                // 章节名
                chapterModel.name = content.substring(lastRange)
                
                // 内容(不包含章节名)
                chapterModel.content = content.substring(NSMakeRange(lastRange.rhs, location - lastRange.rhs))
            }
           
            
            // 章节开头双空格 + 章节纯内容
            chapterModel.content = TypeSetting.readSpace + chapterModel.content.removeSEHeadAndTail
            
            // 设置上一个章节ID
            chapterModel.previousChapterID = lastChapterModel?.id ?? nil
            
            // 设置下一个章节ID
            if i == (count - 1) { // 最后一个章节了
                chapterModel.nextChapterID = nil
            }
            else{
                lastChapterModel?.nextChapterID = chapterModel.id
            }
            
            // 保存
            chapterModel.persist()
            lastChapterModel?.persist()
            
            // 记录
            lastRange = range
            lastChapterModel = chapterModel
            
            // 通过章节内容生成章节列表
            chapterListModels.append(chapterModel.chapterList)
        }
        
        // 返回
        return chapterListModels
    }
1.1.3 产生阅读模型
        // 阅读模型
        let readModel = ReadModel.model(bookID: bookID)
        
        // 记录章节列表
        readModel.chapterListModels = chapterListModels
        
        // 设置第一个章节为阅读记录
        readModel.recordModel?.modify(chapterID:  readModel.chapterListModels.first!.id, toPage: 0)

拿到阅读模型,展示出来,就可以看书了

1.2 视图呈现

  • 阅读文本视图 ReadView ->
  • 阅读控制器,添加状态栏 ReadViewController ->
  • 阅读的主控制器 ( 带菜单功能的 )->
  • 主控制器的,翻页模式处理
1.2.1, 阅读文本视图 ReadView

制定一页的模型 ReadPageModel, 产生一帧文本 CTFrame, 文本绘制到界面上,ok

class ReadView: UIView {
    
    /// 当前页模型  ( 使用contentSize 绘制)
    var pagingModel:ReadPageModel! {
        
        didSet{
            
            frameRef = CoreText.GetFrameRef(attrString: pagingModel.showContent, rect: CGRect(origin: CGPoint.zero, size: pagingModel.contentSize))
        }
    }
    
    
    /// CTFrame
    var frameRef:CTFrame? {
        
        didSet{
            
            if frameRef != nil { setNeedsDisplay() }
        }
    }
    
    
    /// 绘制
    override func draw(_ rect: CGRect) {
        guard let frame = frameRef, let ctx = UIGraphicsGetCurrentContext() else {
            return
        }
        ctx.textMatrix = CGAffineTransform.identity
        ctx.translateBy(x: 0, y: bounds.size.height)
        ctx.scaleBy(x: 1.0, y: -1.0)
        CTFrameDraw(frame, ctx)
    }
    
}

1.2.2, 阅读控制器,添加状态栏 ReadViewController
  • 添加顶部状态栏,顶部有书名和章节名

  • 添加底部状态栏,底部有当前的进度

  • 阅读视图展示。是首页,展示封面。不是,就展示正文

class ReadViewController: ViewController {
    
    // 需要两个对象, 当前页阅读记录 和 阅读对象
    
    /// 当前页阅读记录对象
    var recordModelBasic:ReadRecordModel!

    /// 阅读对象  (  用于显示书名以及书籍首页显示书籍信息  )
    weak var readModel:ReadModel!
    
    /// 顶部状态栏
    var topView:ReadViewStatusTopView!
    
    /// 底部状态栏
    var bottomView:ReadViewStatusBottomView!
    
    /// 阅读视图
    private var readView:ReadView!
    
    /// 书籍首页视图, 封面
    private var homeView:ReadHomeView!
}
1.2.3, 阅读的主控制器 ( 带菜单功能的 )
  • 添加左侧弹窗, 章节列表, 和书签

  • 添加设置菜单

菜单包括:

顶部栏,书签按钮和返回按钮

底部栏,上一章按钮、下一章按钮和进度拖动, 目录入口和设置入口

设置栏,控制字体大小和种类,控制翻页方式,控制进度展示方式

  • 添加阅读容器视图

阅读容器视图,上面是控制翻页方式的控制器的视图

控制翻页方式的控制器,管理上一步的阅读控制器 ReadViewController

class ReadController: ViewController{

    // MARK: 数据相关
    
    /// 阅读对象
    let readModel:ReadModel
    
    
    // MARK: UI相关
    
    /// 阅读容器视图
    var contentView = ReadContentView(frame: CGRect(x: 0, y: 0, width: ScreenWidth, height: ScreenHeight))
    
    /// 左侧弹窗: 章节列表, 和书签
    var leftView = ReadLeftView(frame: CGRect(x: -READ_LEFT_VIEW_WIDTH, y: 0, width: READ_LEFT_VIEW_WIDTH, height: ScreenHeight))
    
    /// 底部设置菜单
    lazy var readMenu = ReadMenu(vc: self, delegate: self)
    
    
    //	控制翻页方式的控制器:
    
    /// 翻页控制器 (仿真)
    var pageViewController:UIPageViewController!
    
    /// 翻页控制器 (滚动)
    var scrollController:ReadViewScrollController!
    
    /// 翻页控制器 (无效果,覆盖)
    var coverController:CoverController!
    
    /// 非滚动模式时,当前显示 ReadViewController
    var currentDisplayController:ReadViewController?
  }
1.2.4, 翻页模式处理

翻页模式,有仿真、平移和滚动

这里以仿真为例子:

仿真的效果,使用 UIPageViewController

  • 先添加 UIPageViewController 的视图,到阅读容器视图 contentView 上面
func creatPageController(displayController:ReadViewController? = nil) {
            guard let displayCtrl = displayController else {
                return
            }
            
            // 创建
            let options = [UIPageViewController.OptionsKey.spineLocation : NSNumber(value: UIPageViewController.SpineLocation.min.rawValue)]
            
            pageViewController = UIPageViewController(transitionStyle: .pageCurl,navigationOrientation: .horizontal,options: options)
            
            pageViewController.delegate = self
            
            pageViewController.dataSource = self
            
            // 翻页背部带文字效果
            pageViewController.isDoubleSided = true
            
            contentView.insertSubview(pageViewController.view, at: 0)
            
            pageViewController.view.backgroundColor = UIColor.clear
            
            pageViewController.view.frame = contentView.bounds
            
            pageViewController.setViewControllers([displayCtrl], direction: .forward, animated: false, completion: nil)

}
  • 提供分页控制器的内容,即阅读内容

以下是获取下一页的代码,

获取上一页的,类似

/// 获取下一页
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        
        tempNumber += 1
        
        // 获取当前页阅读记录
        var recordModel:ReadRecordModel? = (viewController as? ReadViewController)?.recordModelBasic
        
        // 如果没有则从背面页面获取
        if recordModel == nil {
            
            recordModel = (viewController as? ReadViewBGController)?.recordModel
        }
        
        if abs(tempNumber) % 2 == 0 { // 背面
            return getBackgroundController(recordModel: recordModel)
        }
        else{
            // 内容
            recordModel = getBelowReadRecordModel(recordModel: recordModel)
            return getReadController(recordModel: recordModel)
        }
    }



这样,.txt 的小说,可读一下了

2,计算页码

一个章节有几页,是怎么计算出来的?

先拿着一个章节的富文本,和显示区域,计算出书页的范围

通常显示区域,是放不满一章的。

显示区域先放一页,得到这一页的开始范围和长度,对应一个 ReadPageModel

显示区域再放下一页 ...

/// 获得内容分页列表
    /// - Parameters:    - attrString: 内容     - rect: 显示范围
    /// - Returns: 内容分页列表
    class func pagingRanges(attrString:NSAttributedString, rect:CGRect) ->[NSRange] {
        var rangeArray = [NSRange]()
        let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
        let path = CGPath(rect: rect, transform: nil)
        var range = CFRangeMake(0, 0)
        var rangeOffset = 0
        repeat{
            let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(rangeOffset, 0), path, nil)
            range = CTFrameGetVisibleStringRange(frame)
            rangeArray.append(NSMakeRange(rangeOffset, range.length))
            rangeOffset += range.length
        }while(range.location + range.length < attrString.length)
        return rangeArray
    }

拿上一步计算出来的范围,创建该章节每一页的模型 ReadPageModel


/// 内容分页
    /// - Parameters:     - attrString: 内容     - rect: 显示范围
    ///   - isFirstChapter: 是否为本文章第一个展示章节,如果是则加入书籍首页
    /// - Returns: 内容分页列表
    class func pageing(attrString:NSAttributedString, rect:CGRect, isFirstChapter:Bool = false) ->[ReadPageModel] {
        
        var pageModels = [ReadPageModel]()
        
        if isFirstChapter { // 第一页为书籍页面
            
            let pageModel = ReadPageModel()
            
            pageModel.pageRange = NSMakeRange(TypeSetting.readBookHomePage, 1)
            
            pageModel.contentSize = READ_VIEW_RECT.size
            
            pageModels.append(pageModel)
        }
        
        let ranges = CoreText.pagingRanges(attrString: attrString, rect: rect)
        
        if !ranges.isEmpty {
            
            let count = ranges.count
            
            for i in 0..<count {
                
                let range = ranges[i]
                
                let pageModel = ReadPageModel()
                
                let content = attrString.attributedSubstring(from: range)
                
                pageModel.pageRange = range
                
                pageModel.content = content
                
                pageModel.page = i
                // ...
                // 内容Size (滚动模式 || 长按菜单)
                let maxW = READ_VIEW_RECT.width
                
                pageModel.contentSize = CGSize(width: maxW, height: CoreText.GetAttrStringHeight(attrString: content, maxW: maxW))
                
                //	...
                
                pageModels.append(pageModel)
            }
        }
        
        return pageModels
    }

该章节 ReadPageModel 的数目,就是该章节有几页

2.1 翻页

获取下一页的代码

翻一页,就是当前的 ReadRecordModel , 翻到下一页,

交给阅读控制器去呈现, ReadViewController 的子类 ReadLongPressViewController

标准的模型更新,刷新视图
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        
          //   ...
         // 内容
         recordModel = recordModel?.getBelowReadRecordModel
         return getReadController(recordModel: recordModel)
        
    }
    
    
    /// 获取指定阅读记录阅读页
    func getReadController(recordModel:ReadRecordModel!) ->ReadViewController? {
        
        if recordModel != nil {
                // 需要返回支持长按的控制器
                
                let controller = ReadLongPressViewController()
                
                controller.recordModelBasic = recordModel
                
                controller.readModel = readModel
                
                return controller
        }
        
        return nil
    }
阅读记录模型:

ReadRecordModel, 主要是三个属性,

一本小说,绑定一个进度,需要小说 ID

当前看到那一章,有一个章节的模型 ReadChapterModel

当前这一章,看到第几页了,有一个页码 page,

可以计算出,

当前阅读到的这一屏的,页面模型 ReadPageModel

和当前阅读到的这一屏的富文本 contentAttributedString,用来渲染

class ReadRecordModel: NSObject,NSCoding {

    /// 小说ID
    var bookID:String!
    
    /// 当前记录的阅读章节
    var chapterModel:ReadChapterModel!
    
    /// 阅读到的页码
    var page:Int = 0

	   /// 当前记录分页模型
    var pageModel:ReadPageModel{
        chapterModel.pageModels[page]
    }
    
        /// 当前记录页码富文本
    var contentAttributedString:NSAttributedString {
        chapterModel.contentAttributedString(page: page)
    }
    

ReadRecordModel, 翻页的计算逻辑:

本章内,页码 + 1, 就好了, page 处理下

本章最后一页了,换下一章

本章到了最后一章,最后一页了,就翻不动了

   /// 获取当前记录下一页阅读记录
    var getBelowReadRecordModel: ReadRecordModel?{
           // ...
          // 复制
          let recordModel = copyModel
          
          // 书籍ID
          // 章节ID
          guard let bookID = recordModel.bookID, let chapterID = recordModel.chapterModel.nextChapterID else{
              return nil
          }
          
          // 最后一章 最后一页
          if recordModel.isLastChapter, recordModel.isLastPage {
              
              Log("已经是最后一页了")
              
              return nil
          }
          
          // 最后一页
          if recordModel.isLastPage {
              
              // 检查是否存在章节内容
              if ReadChapterModel.isExist(bookID: bookID, chapterID: chapterID){
                  // 修改阅读记录
                  recordModel.modify(chapterID: chapterID, toPage: 0, isSave: false)
                  
              }
          }else{
              recordModel.nextPage()
          }
          
          return recordModel
      }

3,目录

目录展示,比较简单

把上文解析出来的目录模型 ChapterBriefModel ,用一个列表展示就好了

滚动到阅读记录

譬如,当前阅读到第 50 章了,打开目录,显示第一章,不太好。

需要滚动到,阅读记录对应的章节

当前阅读进度,使用 recordModel 追踪,

从目录 ChapterBriefModel 列表中,找出 recordModel 的章节模型的 id,就好了

ChapterBriefModelReadChapterModel 的 id 是一一对应的


/// 滚动到阅读记录
    func scrollRecord() {
        
        if let read = readModel, let record = read.recordModel {
            
            tableView.reloadData()
       
            if let chapterListModel = (read.chapterListModels as NSArray).filtered(using: NSPredicate(format: "id == %ld", record.chapterModel.id)).first as? ChapterBriefModel{

                tableView.scrollToRow(at: read.chapterListModels.firstIndex(of: chapterListModel)!.ip, at: .middle, animated: false)
            }
        }
    }

4,书签

从读到的位置,添加书签。

书签列表中,用书签,返回读到的位置

书签的数据结构

一个书签,绑定具体的小说,与该小说的某个章节

书签,最好能展示一些上次阅读的信息,content

要从书签,返回到阅读到的地方,需要一个位置 location

class ReadMarkModel: NSObject,NSCoding {

    /// 小说ID
    var bookID:String!
    
    /// 章节ID
    var chapterID: Int!
    
    /// 章节名称
    var name:String!
    
    // 内容, 
    // 对应当前屏幕阅读模型  ReadPageModel 的内容
    var content:String!
    
    /// 时间戳
    var time = NSNumber(value: 0)
    
    /// 位置
    // 对应当前屏幕阅读模型  ReadPageModel 的范围的开始点
    var location: Int = 0

4.1 创建书签

从读到的位置,添加书签。

书签的展示,采用逆序。最新的,摆在前面,也就是最近添加的。


/// 添加书签,默认使用当前阅读记录!
    func insetMark(recordModel:ReadRecordModel? = nil) {
        
        let recordModel = (recordModel ?? self.recordModel)!
        
        let markModel = ReadMarkModel()
        
        markModel.bookID = recordModel.bookID
        
        markModel.chapterID = recordModel.chapterModel.id
        
        if recordModel.pageModel.isHomePage {
            
            markModel.name = "(无章节名)"
            
            markModel.content = bookID
            
        }else{
            
            markModel.name = recordModel.chapterModel.name
            
            // 当前屏幕阅读模型  ReadPageModel 的内容,稍微处理了下
            markModel.content = recordModel.contentString.removeSEHeadAndTail.removeEnterAll
        }
        
        markModel.time = NSNumber(value: Timer1970())
        // location, 对应当前屏幕阅读模型  ReadPageModel 的范围的开始点
        markModel.location = recordModel.locationFirst
        
        if markModels.isEmpty {
            
            markModels.append(markModel)
            
        }else{
            
            markModels.insert(markModel, at: 0)
        }
        
        // ...
    }

4.2 从书签列表,选择书签,返回读到的地方

点击具体的书签,先处理下 UI,

再拿着章节 ID 和该章节的位置,去跳转上次读到的地方

    // MARK: ReadMarkViewDelegate
extension ReadController: ReadMarkViewDelegate{
    /// 书签列表选中书签
    func markViewClickMark(markView: ReadMarkView, markModel: ReadMarkModel) {
        
        showLeftView(isShow: false)
        
        contentView.showCover(isShow: false)
        
        goToChapter(chapterID: markModel.chapterID, coordinate: markModel.location)
    }
}

跳转上次读到的地方,就是拿记录阅读位置的 ReadRecordModel,

更新他的章节模型 ReadChapterModel, 和阅读到的位置 page

模型更新,刷新 UI 界面,就是跳转过去了

 /// 跳转指定章节(指定坐标)
    func goToChapter(chapterID: Int, coordinate location: Int) {
        // 复制阅读记录
         let recordModel = readModel.recordModel?.copyModel
         
         // 书籍ID
         guard let bookID = recordModel?.bookID else{
             return
         }
        
         // 检查是否存在章节内容
         // 存在
         if ReadChapterModel.isExist(bookID: bookID, chapterID: chapterID){
             // 坐标定位
             recordModel?.modifyLoc(chapterID: chapterID, location: location, isSave: false)
         
             // 阅读阅读记录
             if let record = recordModel{
                 update(read: record)
             }
             // 展示阅读
             creatPageController(displayController: getReadController(recordModel: recordModel))
             
         }
    }

ReadRecordModel, 更新内容

刷新章节目录 ReadChapterModel ,比较简单,拿书的 id 和章节 id, 去创建新的

该章节中,阅读到第几页,需要在 ReadChapterModel 中计算下


class ReadRecordModel{

/// 修改阅读记录为指定章节位置
    func modifyLoc(chapterID: Int, location: Int, isSave:Bool = true) {
        
        if ReadChapterModel.isExist(bookID: bookID, chapterID: chapterID) {
            
            chapterModel = ReadChapterModel(id: chapterID, in: bookID).real
            
            page = chapterModel.page(location: location)
            
            if isSave { save() }
        }
    }
    
}

ReadChapterModel 中计算,该章节阅读到第几页

拿着位置,在章节模型的书页模型列表中比较范围,得出

class ReadChapterModel{
    // 获取存在指定坐标的页码
    func page(location: Int?) -> Int {
        
        let count = pageModels.count
        guard let loc = location else {
            return 0
        }
        for i in 0..<count {
            
            let range = pageModels[i].pageRange!
            
            if loc < range.rhs {
                
                return i
            }
        }
        
        return 0
    }
}

总结上文,功能主要是,各种模型之间的对应关系,转换来,转换去,跟数据库操作很像

5,滚动条,调进度

调进度分两种,

  • 全文滚动,拉章节

全文百分比展示进度,滚动是在全文范围内的;

  • 章节滚动,拉书页

当前章节展示进度,按页码,滚动是在当前章节范围内的

5.1 全文的进度展示与调节

全文百分比展示进度

拿着当前的阅读记录,去计算

  • 是尾页,则很好计算

  • 不是尾页,

先算出当前章初始位置的进度 ,chapterIndex/chapterCount

再加上当前页,在当前章的进度, (locationFirst / fullContentLength)/chapterCount

算的精度一般,把每一章的长度,等价了
extension ReadModel{
    
    /// 计算总进度
    func progress(readTotal recordModel:ReadRecordModel!) ->Float {
        
        // 当前阅读进度
        var progress:Float = 0
        
        // 临时检查
        if recordModel == nil { return progress }
        
        if recordModel.isLastChapter, recordModel.isLastPage { // 最后一章最后一页
            
            // 获得当前阅读进度
            progress = 1.0
            
        }else{
            
            // 当前章节在所有章节列表中的位置
            let chapterIndex = Float(recordModel.chapterModel.priority)
            
            // 章节总数量
            let chapterCount = Float(chapterListModels.count)
            
            // 阅读记录首位置
            let locationFirst = Float(recordModel.locationFirst)
            
            // 阅读记录内容长度
            let fullContentLength = Float(recordModel.chapterModel.fullContent.length)
            
            // 获得当前阅读进度
            progress = chapterIndex/chapterCount + (locationFirst / fullContentLength)/chapterCount
        }
        
        // 返回
        return progress
    }
}
滚动是在全文范围内,只能拉到某一章的开头

滚动条代理中,找到滚动的范围,

用该范围,找出目录列表中,对应的那一章,

跳过去,就好了

  • 拉到底,就跳尾页

  • 没拉到底,就跳到那一章的开头

/// 进度显示将要隐藏
    func sliderWillHidePopUpView(_ slider: ASValueTrackingSlider!) {
       
            // 有阅读数据
            let readModel = readMenu.vc.readModel
            
            // 有阅读记录以及章节数据
            if readModel.recordModel?.chapterModel != nil{
                
                // 总章节个数
                let count = (readModel.chapterListModels.count - 1)
                
                // 获得当前进度的章节索引
                let index = Int(Float(count) * slider.value)
                
                // 获得章节列表模型
                let chapterListModel = readModel.chapterListModels[index]
                
                // 页码
                let toPage = (index == count) ? ReadingConst.lastPage : 0
                
                // 传递
                readMenu?.delegate?.readMenuDraggingProgress(readMenu: readMenu, toChapterID: chapterListModel.id, toPage: toPage)
            }
            
        }

下面两个方法,

就是模型更新,刷新界面

模型更新,就是更新当前阅读记录模型 ReadRecordModel 的位置

/// 拖拽章节进度
    func readMenuDraggingProgress(readMenu: ReadMenu, toChapterID: Int, toPage: Int) {

        // 不是当前阅读记录章节
        if toChapterID != readModel.recordModel?.chapterModel.id{
            
            goToChapter(chapterID: toChapterID, to: toPage)
            
            // 检查当前内容是否包含书签
            readMenu.topView.checkForMark()
        }
    }


/// 跳转指定章节的指定页面
    func goToChapter(chapterID: Int, to page: Int = 0) {
        
        // 复制阅读记录
         let recordModel = readModel.recordModel?.copyModel
         
         // 书籍ID
         guard let bookID = recordModel?.bookID else{
             return
         }
        
         // 检查是否存在章节内容
         // 存在
         if ReadChapterModel.isExist(bookID: bookID, chapterID: chapterID){
              // 分页定位
             recordModel?.modify(chapterID: chapterID, toPage: page, isSave: false)
             
             
             // 阅读阅读记录
             if let record = recordModel{
                 update(read: record)
             }
             // 展示阅读
             creatPageController(displayController: getReadController(recordModel: recordModel))
             
         }
    }


5.2 当前章节的进度展示与调节

分页进度,

进度就靠 当前阅读记录模型 ReadRecordModel

    /// 刷新阅读进度显示
    private func reloadProgress() {
            // 分页进度
            if let record = vc.readModel.recordModel{
                bottomView.progress.text = "\(record.page + 1)/\(record.chapterModel!.pageCount)"
            }
            // 显示进度
      
    }
滚动是在当前章节范围内,拉到某一书页

前往某一书页,就是更新当前阅读记录模型 ReadRecordModel 的位置,

刷新界面

/// 进度显示将要隐藏
    func sliderWillHidePopUpView(_ slider: ASValueTrackingSlider!) {
            // 分页进度
            readMenu?.delegate?.readMenuDraggingProgress(readMenu: readMenu, toPage: Int(slider.value - 1))
        
    }

当前章节范围内,更新当前阅读记录模型 ReadRecordModel 的页码,

比较简单


/// 拖拽阅读记录
    func readMenuDraggingProgress(readMenu: ReadMenu, toPage: Int) {
        
        if readModel.recordModel?.page != toPage{
            
            readModel.recordModel?.page = toPage
            
            creatPageController(displayController: getCurrentReadViewController())
            
            // 检查当前内容是否包含书签
            readMenu.topView.checkForMark()
        }
    }

GitHub 链接