美文网首页
iOS 小说阅读器-WLReader 介绍

iOS 小说阅读器-WLReader 介绍

作者: 皮乐皮儿 | 来源:发表于2024-06-08 17:16 被阅读0次

    一款完整的小说阅读器功能包含:

    • 阅读主页面的图文混排
    • 翻页效果:仿真,平移,滚动,覆盖,无效果
    • 设置功能:字号更改,字体更改,阅读背景设置,亮度调整,章节切换,查看大图,笔记划线,书签标记
    • 阅读记录
    • 网络书籍下载,本地书籍解析
    • 长按选中可复制和评论
    • DEMO地址

    录屏效果 录屏

    背景色调整.png 笔记划线.png 翻页.png 设置.png 长按选中.png 主页面.png 字体.png

    小说阅读器主要包含三个模块:

    • 解析:包含txt, epub 书籍解析,也包含网络和本地书籍解析,最终生成章节富文本,分页等处理
    • 显示:图文混排,对解析出来的html文件或者txt文件进行富文本展示处理
    • 功能:包含翻页,字体,字号,背景色,行间距调整,章节切换,书签,笔记,阅读记录,数据库保存等处理

    先来说一说解析

    CoreParser

    解析的核心类,内部分txt, epub解析

    截屏2024-06-09 17.12.55.png

    文件结构如上

    核心代码如下:

    // MARK - 开始解析
    
        func parseBook(parserCallback: @escaping (WLBookModel?, Bool) ->()) {
    
            self.parserCallback = parserCallback
    
            DispatchQueue.global().async {
    
                switch self.bookType {
    
                case .Epub:
    
                    self.parseEpubBook()
    
                case .Txt:
    
                    self.parseTxtBook()
    
                default:
    
                    print("暂时无法解析")
    
                }
    
            }
    
        }
    

    txt 解析

    • 解析章节,正则分割出对应的章节标题
    • 章节分页
    func parseBook(path: String!) throws -> WLTxtBook {
    
            let url = URL.init(fileURLWithPath: path)
    
            do {
    
                let content = try String.init(contentsOf: url, encoding: String.Encoding.utf8)
    
                var models: [WLTxtChapter] = []
    
                var titles = Array<String>()
    
                // 构造读书数据模型
    
                let document = NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.documentDirectory, FileManager.SearchPathDomainMask.userDomainMask, true).first
    
                let newPath: NSString = path as NSString
    
                let fileName = newPath.lastPathComponent.split(separator: ".").first
    
                let bookPath = document! + "/Books/\(String(fileName!))"
    
                if FileManager.default.fileExists(atPath: bookPath) == false {
    
                    try? FileManager.default.createDirectory(atPath: bookPath, withIntermediateDirectories: true, attributes: nil)
    
                }
    
                
    
                let results = WLTxtParser.doTitleMatchWith(content: content)
    
                if results.count == 0 {
    
                    let model = WLTxtChapter()
    
                    model.title = "开始"
    
                    model.path = path
    
                    models.append(model)
    
                }else {
    
                    var endIndex = content.startIndex
    
                    for (index, result) in results.enumerated() {
    
                        let startIndex = content.index(content.startIndex, offsetBy: result.range.location)
    
                        endIndex = content.index(startIndex, offsetBy: result.range.length)
    
                        let currentTitle = String(content[startIndex...endIndex])
    
                        titles.append(currentTitle)
    
                        let chapterPath = bookPath + "/chapter" + String(index + 1) + ".txt"
    
                        let model = WLTxtChapter()
    
                        model.title = currentTitle
    
                        model.path = chapterPath
    
                        models.append(model)
    
                        
    
                        if FileManager.default.fileExists(atPath: chapterPath) {
    
                            continue
    
                        }
    
                        var endLoaction = 0
    
                        if index == results.count - 1 {
    
                            endLoaction = content.count - 1
    
                        }else {
    
                            endLoaction = results[index + 1].range.location - 1
    
                        }
    
                        let startLocation = content.index(content.startIndex, offsetBy: result.range.location)
    
                        let subString = String(content[startLocation...content.index(content.startIndex, offsetBy: endLoaction)])
    
                        try! subString.write(toFile: chapterPath, atomically: true, encoding: String.Encoding.utf8)
    
                        
    
                    }
    
                    self.book.chapters = models
    
                }
    
                return self.book
    
            }catch {
    
                print(error)
    
                throw error
    
            }
    
        }
    

    章节标题解析的正则方法:

    
    class func doTitleMatchWith(content: String) -> [NSTextCheckingResult] {
    
            let pattern = "第[ ]*[0-9一二三四五六七八九十百千]*[ ]*[章回].*"
    
            let regExp = try! NSRegularExpression(pattern: pattern, options: NSRegularExpression.Options.caseInsensitive)
    
            let results = regExp.matches(in: content, options: .reportCompletion, range: NSMakeRange(0, content.count))
    
            return results
    
        }
    

    根据章节模型,生成章节对应的富文本

    class func attributeText(with chapterModel: WLBookChapter!) -> NSMutableAttributedString! {
    
            let tmpUrl = chapterModel.fullHref!
    
            let tmpString = try? String.init(contentsOf: tmpUrl, encoding: String.Encoding.utf8)
    
            if tmpString == nil {
    
                return nil
    
            }
    
            let textString: String = tmpString!
    
            
    
            let results = doTitleMatchWith(content: textString)
    
            var titleRange = NSRange(location: 0, length: 0)
    
            if results.count != 0 {
    
                titleRange = results[0].range
    
            }
    
            let startLocation = textString.index(textString.startIndex, offsetBy: titleRange.location)
    
            let endLocation = textString.index(startLocation, offsetBy: titleRange.length - 1)
    
            let titleString = String(textString[startLocation...endLocation])
    
            let contentString = String(textString[textString.index(after: endLocation)...textString.index(before: textString.endIndex)])
    
            let paraString = formatChapterString(contentString: contentString)
    
            
    
            let paragraphStyleTitle = NSMutableParagraphStyle()
    
            paragraphStyleTitle.alignment = NSTextAlignment.center
    
            let dictTitle:[NSAttributedString.Key: Any] = [.font:UIFont.boldSystemFont(ofSize: 19),
    
                                                           .paragraphStyle:paragraphStyleTitle]
    
            
    
            let paragraphStyle = NSMutableParagraphStyle()
    
            paragraphStyle.lineHeightMultiple = WLBookConfig.shared.lineHeightMultiple
    
            paragraphStyle.paragraphSpacing = 20
    
            paragraphStyle.alignment = NSTextAlignment.justified
    
            let font = UIFont.systemFont(ofSize: 16)
    
            let dict: [NSAttributedString.Key: Any] = [.font:font,
    
                                                       .paragraphStyle:paragraphStyle,
    
                                                       .foregroundColor:UIColor.black]
    
            
    
            let newTitle = "\n" + titleString + "\n\n"
    
            let attrString = NSMutableAttributedString.init(string: newTitle, attributes: dictTitle)
    
            let content = NSMutableAttributedString.init(string: paraString, attributes: dict)
    
            attrString.append(content)
    
            
    
            return attrString
    
        }
    

    txt 解析最终生成的是 WLTxtBook 模型:

    public class WLTxtChapter:NSObject {
    
        var content: String?
    
        var title: String!
    
        var page: Int! // 页数
    
        var count: Int! // 字数
    
        var path: String!
    
    }
    
      
    
    
    open class WLTxtBook: NSObject {
    
        var bookId: String!
    
        var title: String!
    
        var author: String!
    
        var directory: URL!
    
        var contentDirectory: URL!
    
        var chapters: [WLTxtChapter]!
    
    }
    

    它包含所有的章节数据模型,为了避免内存浪费,加快渲染速度,需要对章节的分页进行按需加载,下面也会介绍到预加载效果,在展示当前章节时,对上一章和下一章进行提前分页处理

    epub 解析

    借鉴了 FolioReaderKit 的解析方式,具体的可以查看 Demo

    阅读器使用的model

    截屏2024-06-09 17.13.36.png

    外界可使用的主要是 WLBookModel

    /// 目前使用文件名作为唯一ID,因为发现有的电子书没有唯一ID
    
        public var bookId: String!
    
        /// 书名
    
        public var title: String!
    
        /// 作者
    
        public var author: String!
    
        public var directory: URL!
    
        public var contentDirectory: URL!
    
        public var coverImg: String!
    
        public var desc: String!
    
        /// 当前是第几章
    
        public var chapterIndex:Int! = 0
    
        /// 当前是第几页
    
        public var pageIndex:Int! = 0
    
        /// 所有的章节
    
        public var chapters:[WLBookChapter]! = [WLBookChapter]()
    
        /// 书籍更新时间
    
        public var updateTime: TimeInterval! // 更新时间
    
        /// 阅读的最后时间
    
        public var lastTime: String!
    
        /// 是否已下载
    
        public var isDownload:Bool! = false
    
        /// 当前图书类型
    
        public var bookType:WLBookType!
    
        private var txtParser:WLTxtParser!
    
        /// 包含笔记的章节
    
        public var chapterContainsNote:[WLBookNoteModel]! = [WLBookNoteModel]()
    
        /// 包含书签的章节
    
        public var chapterContainsMark:[WLBookMarkModel]! = [WLBookMarkModel]()
    

    它是非常重要的数据模型,里面包含了书籍相关的所有信息

    章节解析:

    // MARK - 解析epub章节
    
         private func chapterFromEpub(epub: WLEpubBook) {
    
            // flatTableOfContents 代表有多少章节
    
            for (index, item) in epub.flatTableOfContents.enumerated() {
    
                // 创建章节数据
    
                let chapter = WLBookChapter()
    
                chapter.title = item.title
    
                chapter.isFirstTitle = item.children.count > 0
    
                chapter.fullHref = URL(fileURLWithPath: item.resource!.fullHref)
    
                chapter.chapterIndex = index
    
                chapters.append(chapter)
    
            }
    
        }
    
    /// txt分章节
    
        private func chapterFromTxt(txt: WLTxtBook) {
    
            for (index, txtChapter) in txt.chapters.enumerated() {
    
                let chapter = WLBookChapter()
    
                chapter.title = txtChapter.title
    
                chapter.isFirstTitle = txtChapter.page == 0
    
                chapter.fullHref = URL(fileURLWithPath: txtChapter.path)
    
                chapter.chapterIndex = index
    
                chapter.chapterContentAttr = WLTxtParser.attributeText(with: chapter)
    
                chapters.append(chapter)
    
            }
    
        }
    

    分页时需要调用:

    /// 对当前章节分页
    
        public func paging(with currentChapterIndex:Int! = 0) {
    
            let chapter = self.chapters[safe: currentChapterIndex]
    
            chapter?.paging()
    
        }
    

    章节数据模型

    class WLBookChapter: NSObject {
    
        /// 章节标题
    
        var title:String!
    
        /// 是否隐藏
    
        var linear:Bool!
    
        /// 章节完整地址
    
        var fullHref:URL!
    
        /// 是否是一级标题
    
        var isFirstTitle:Bool! = false
    
        /// 当前章节分页
    
        var pages:[WLBookPage]! = [WLBookPage]()
    
        /// 当前章节的所有内容
    
        var chapterContentAttr:NSMutableAttributedString!
    
        /// 当前是第几章
    
        var chapterIndex:Int! = 0
    
        /// 用于滚动模式
    
        var contentHeight:CGFloat! = 0
    
        /// 是否强制分页,比如更改字体,字号,行间距等需要强制分页,默认是不需要的
    
        var forcePaging:Bool = false
    
    }
    
    

    分页

    在html加载显示上,我选用的是 DTCoreText 这个渲染库,它对于html的渲染支持非常好,在分页时也是使用其布局相关的一些特性处理的,具体的操作如下:

    let layouter = DTCoreTextLayouter.init(attributedString: chapterContentAttr)
    
            let rect = CGRect(origin: .zero, size: config.readContentRect.size)
    
            var frame = layouter?.layoutFrame(with: rect, range: NSRange(location: 0, length: chapterContentAttr.length))
    
            
    
            var pageVisibleRange:NSRange! = NSRange(location: 0, length: 0)
    
            var rangeOffset = 0
    
            var count = 1
    
            repeat {
    
                frame = layouter?.layoutFrame(with: rect, range: NSRange(location: rangeOffset, length: chapterContentAttr.length - rangeOffset))
    
                pageVisibleRange = frame?.visibleStringRange()
    
                if pageVisibleRange == nil {
    
                    rangeOffset = 0
    
                    continue
    
                }else {
    
                    rangeOffset = pageVisibleRange!.location + pageVisibleRange!.length
    
                }
    
                let pageContent = chapterContentAttr.attributedSubstring(from: pageVisibleRange!)
    
                let pageModel = WLBookPage()
    
                pageModel.content = pageContent
    
                pageModel.contentRange = pageVisibleRange
    
                pageModel.page = count - 1
    
                pageModel.chapterContent = chapterContentAttr
    
                pageModel.pageStartLocation = pageVisibleRange.location
    
                if WLBookConfig.shared.currentChapterIndex == self.chapterIndex && WLBookConfig.shared.currentPageLocation >= pageVisibleRange.location && WLBookConfig.shared.currentPageLocation <= pageVisibleRange.location + pageVisibleRange.length {
    
                    WLBookConfig.shared.currentPageIndex = count - 1
    
                }
    
                /// 计算高度
    
                let pageLayouter = DTCoreTextLayouter.init(attributedString: pageContent)
    
                let pageRect = CGRect(origin: .zero, size: CGSizeMake(config.readContentRect.width, .infinity))
    
                let pageFrame = pageLayouter?.layoutFrame(with: pageRect, range: NSRange(location: 0, length: pageContent.length))
    
                pageModel.contentHeight = pageFrame?.intrinsicContentFrame().size.height
    
                pages.append(pageModel)
    
                count += 1
    
            } while rangeOffset <= chapterContentAttr.length && rangeOffset != 0
    

    这里需要注意下,在构建富文本的时候,如果遇到图片等一些特殊节点,需要提前在分页之前进行一些特殊配置,比如图片大小,引用文本的样式,标题的样式处理等

    private func configNoteDispaly(element:DTHTMLElement) {
    
            if element.name == "img" {
    
                setImageDisplay(element: element)
    
            }else if element.name == "h1" || element.name == "h2" {
    
                setHTitleDisplay(element: element)
    
            }else if element.name == "figcaption" {
    
                setFigcaptionDisplay(element: element)
    
            }else if element.name == "blockquote" {
    
                setBlockquoteDisplay(element: element)
    
            }
    
        }
    

    需要针对不同节点做不同处理,具体实现可以参照demo

    页面展示

    截屏2024-06-09 17.14.17.png

    针对不同的翻页效果,做了不同的控制器分类

    /// 默认阅读主视图
        var readViewController:WLReadViewController!
        /// 滚动阅读视图
        var scrollReadController:WLReadScrollController!
        /// 阅读对象
        var bookModel:WLBookModel!
        /// 翻页控制器
        var pageController:WLReadPageController!
        /// 内容容器,实际承载阅读主视图的容器视图
        var container:WLContainerView!
        /// 用于区分仿真翻页的正反面
        var pageCurlNumber:Int! = 1
        /// 平移控制器
        var translationController:WLTranslationController?
        /// 覆盖控制器
        var coverController:WLReaderCoverController?
        /// 图书路径
        var bookPath:String!
        /// 图书解析类
        var bookParser:WLBookParser!
        /// 阅读菜单
        var readerMenu:WLReaderMenu!
        /// 章节列表
        var chapterListView:WLChapterListView!
    

    阅读器的主控制器是 WLReadContainer 它内部包含了所有的翻页控制器,章节列表,设置页面等

    外界调用方式:

    @objc private func fastRead() {
    
            let path = Bundle.main.path(forResource: "张学良传", ofType: "epub")
    
            let read = WLReadContainer()
    
            read.bookPath = path
    
            self.navigationController?.pushViewController(read, animated: true)
    
            
    
        }
    

    只需要传入对应的书籍的path即可,这里可以是本地书籍,也可以是网络链接,内部可以做网络书籍下载处理

    文件处理

    /// 处理文件
    
        private func handleFile(_ path:String) {
    
            let exist = WLFileManager.fileExist(filePath: path)
    
            // 读取配置
    
            WLBookConfig.shared.readDB()
    
            chapterListView.updateMainColor()
    
            if !exist { // 表明没有下载并解压过,需要先下载, 下载成功之后获取下载的文件地址,进行解析
    
                downloadBook(path: path)
    
            }else {
    
                parseBook(path)
    
            }
    
        }
    
    /// 根据path进行解析,解析完成之后再添加阅读容器视图
    
        private func parseBook(_ path:String) {
    
            bookParser = WLBookParser(path)
    
            bookParser.parseBook { [weak self] (bookModel, result) in
    
                if self == nil {
    
                    return
    
                }
    
                if result {
    
                    self!.bookModel = bookModel!
    
                    // 需要从本地读取之间的阅读记录,将对应的章节和page的起始游标读取出来,根据起始游标来算出是本章节的第几页
    
                    let chapterIndex = WLBookConfig.shared.currentChapterIndex!
    
                    let chapterModel = bookModel!.chapters[chapterIndex]
    
                    chapterModel.paging()
    
                    self!.bookModel.pageIndex = WLBookConfig.shared.currentPageIndex
    
                    self!.bookModel.chapterIndex = chapterIndex
    
                    self!.chapterListView.bookModel = bookModel
    
                    WLBookConfig.shared.bottomProgressIsChapter = self!.bookModel.chapters.count > 1
    
                    if bookModel?.chapters.count == 0 {
    
                        self!.showParserFaultPage()
    
                    }else {
    
                        self!.showReadContainerView()
    
                    }
    
                }else {
    
                    self!.showParserFaultPage()
    
                }
    
            }
    
        }
    

    对于翻页效果的处理,主要在 WLReadContainer的几个分类中:

    Page
    /// 创建page容器
    
        func createPageViewController(displayReadController:WLReadViewController? = nil) {
    
            clearPageControllers()
    
            let bookConfig = WLBookConfig.shared
    
            if bookConfig.effetType == .pageCurl { // 仿真
    
                let options = [UIPageViewController.OptionsKey.spineLocation : NSNumber(value: UIPageViewController.SpineLocation.min.rawValue)]
    
                pageController = WLReadPageController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options)
    
                container.insertSubview(pageController.view, at: 0)
    
                pageController.view.backgroundColor = .clear
    
                pageController.view.frame = container.bounds
    
                // 翻页背部带文字效果
    
                pageController.isDoubleSided = true
    
                pageController.delegate = self
    
                pageController.dataSource = self
    
                pageController.setViewControllers(displayReadController == nil ? nil : [displayReadController!], direction: .forward, animated: true)
    
            }else if bookConfig.effetType == .translation {// 平移
    
                translationController = WLTranslationController()
    
                translationController?.delegate = self
    
                translationController?.allowAnimation = true
    
                translationController?.view.frame = container.bounds
    
                container.insertSubview(translationController!.view, at: 0)
    
                translationController?.readerVc = self
    
                translationController?.setViewController(controller: displayReadController!, scrollDirection: .left, animated: true, completionHandler: nil)
    
            }else if bookConfig.effetType == .scroll {// 滚动
    
                scrollReadController = WLReadScrollController()
    
                scrollReadController.readerVc = self
    
                scrollReadController.bookModel = bookModel
    
                scrollReadController.view.frame = container.bounds
    
                container.insertSubview(scrollReadController.view, at: 0)
    
                addChild(scrollReadController)
    
            }else if bookConfig.effetType == .cover {// 覆盖
    
                if displayReadController == nil {
    
                    return
    
                }
    
                coverController = WLReaderCoverController()
    
                coverController?.delegate = self
    
                container.insertSubview(coverController!.view, at: 0)
    
                coverController!.view.frame = container.bounds
    
                coverController?.readerVc = self
    
                coverController!.setController(controller: displayReadController)
    
                
    
            }else if bookConfig.effetType == .no {// 无效果
    
                if displayReadController == nil {
    
                    return
    
                }
    
                coverController = WLReaderCoverController()
    
                coverController?.delegate = self
    
                container.insertSubview(coverController!.view, at: 0)
    
                coverController!.view.frame = container.bounds
    
                coverController?.openAnimate = false
    
                coverController?.readerVc = self
    
                coverController!.setController(controller: displayReadController)
    
            }
    
            readerMenu.updateTopView()
    
        }
    

    这个主要是创建阅读的page容器

    翻页类型的数据层
    • 获取上一页数据
    /// 获取当前页的上一页数据
    
        func getPreviousModel(bookModel:WLBookModel!) -> WLBookModel! {
    
            let previousModel = bookModel.copyReadModel()
    
            // 判断当前页是否是第一页
    
            if previousModel.pageIndex <= 0 {
    
                // 判断当前是否是第一章
    
                if previousModel.chapterIndex <= 0 { // 表示前面没有了
    
                    return nil
    
                }
    
                // 进入到上一章
    
                previousModel.chapterIndex -= 1
    
                // 进入到最后一页
    
                if previousModel.chapters[previousModel.chapterIndex].pages.count == 0 {
    
                    previousModel.chapters[previousModel.chapterIndex].paging()
    
                }
    
                previousModel.pageIndex = previousModel.chapters[previousModel.chapterIndex].pages.count - 1
    
            }else {
    
                // 直接回到上一页
    
                previousModel.pageIndex -= 1
    
            }
    
            return previousModel
    
        }
    
    • 获取下一页数据
    /// 获取当前页的下一页数据
    
        func getNextModel(bookModel:WLBookModel!) -> WLBookModel! {
    
            let nextModel = bookModel.copyReadModel()
    
            // 当前页是本章的最后一页
    
            if nextModel.pageIndex >= nextModel.chapters[nextModel.chapterIndex].pages.count - 1 {
    
                // 判断当前章是否是最后一章
    
                if nextModel.chapterIndex >= nextModel.chapters.count - 1 {
    
                    // 如果是最后一页,表明后面没了
    
                    return nil
    
                }
    
               // 直接进入下一章的第一页
    
                nextModel.chapterIndex += 1
    
                if nextModel.chapters[nextModel.chapterIndex].pages.count == 0 {
    
                    nextModel.chapters[nextModel.chapterIndex].paging()
    
                }
    
                nextModel.pageIndex = 0
    
            }else {// 说明不是最后一页,则直接到下一页
    
                nextModel.pageIndex += 1
    
            }
    
            return nextModel
    
        }
    
    • 获取当前展示数据的主页面
    /// 获取当前阅读的主页面
    
        func createCurrentReadController(bookModel:WLBookModel!) -> WLReadViewController? {
    
            // 提前预加载下一章,上一章数据
    
            if bookModel == nil {
    
                return nil
    
            }
    
            // 刷新阅读进度
    
            readerMenu.reloadReadProgress()
    
            // 刷新章节列表
    
            chapterListView.bookModel = bookModel
    
            // 如果不是滚动状态,则需要提前预加载上一章与下一章内容
    
            if WLBookConfig.shared.effetType != .scroll {
    
                let readVc = WLReadViewController()
    
                let chapterModel = bookModel.chapters[bookModel.chapterIndex]
    
                readVc.bookModel = bookModel
    
                readVc.chapterModel = chapterModel
    
                
    
                let nextIndex = bookModel.chapterIndex + 1
    
                let previousIndex = bookModel.chapterIndex - 1
    
                if nextIndex <= bookModel.chapters.count - 1 {
    
                    bookModel.chapters[nextIndex].paging()
    
                }
    
                if previousIndex >= 0 {
    
                    bookModel.chapters[previousIndex].paging()
    
                }
    
                self.readViewController = readVc
    
                readerMenu.readerViewController = readVc
    
                return readVc
    
            }
    
            return nil
    
        }
    

    在展示当前阅读页面数据的时候,就可以对上一章和下一章的数据进行预加载处理,这里所说的预加载,其实就是对章节数据进行分页

    仿真翻页

    主要是对于翻页上一页和下一页的容器加载和数据处理,具体的数据处理层在上述数据层做了说明

    /// 上一页
    
        func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
    
            readerMenu.showMenu(show: false)
    
            let previousModel = getPreviousModel(bookModel: bookModel)
    
            if WLBookConfig.shared.effetType == .pageCurl { // 仿真
    
                pageCurlNumber -= 1
    
                if abs(pageCurlNumber) % 2 == 0 {
    
                    return createBackReadController(bookModel: previousModel)
    
                }
    
                return createCurrentReadController(bookModel: previousModel)
    
            }
    
            return createCurrentReadController(bookModel: previousModel)
    
        }
    
        /// 下一页
    
        func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
    
            readerMenu.showMenu(show: false)
    
            if WLBookConfig.shared.effetType == .pageCurl { // 仿真
    
                pageCurlNumber += 1
    
                if abs(pageCurlNumber) % 2 == 0 {
    
                    return createBackReadController(bookModel: bookModel)
    
                }
    
                let nextModel = getNextModel(bookModel: bookModel)
    
                return createCurrentReadController(bookModel: nextModel)
    
            }
    
            let nextModel = getNextModel(bookModel: bookModel)
    
            return createCurrentReadController(bookModel: nextModel)
    
        }
    

    如果想要仿真翻页更加的真是,在翻页的时候背面应该是当前页的翻转显示,具体也就是上面所说的背面控制器,它就是对当前展示视图的view做了一层旋转

    /// 获取背面阅读控制器,主要用于仿真翻页
    
        func createBackReadController(bookModel:WLBookModel!) -> WLReadBackController? {
    
            if WLBookConfig.shared.effetType == .pageCurl {
    
                if bookModel == nil {
    
                    return nil
    
                }
    
                let vc = WLReadBackController()
    
                vc.targetView = createCurrentReadController(bookModel: bookModel)?.view
    
                return vc
    
            }
    
            return nil
    
        }
    
    /// 绘制目标视图的反面
    
        private func drawTargetBack() {
    
            // 展示图片
    
            if targetView != nil {
    
                let rect = targetView.frame
    
                UIGraphicsBeginImageContextWithOptions(rect.size, true, 0.0)
    
                let context = UIGraphicsGetCurrentContext()
    
                let transform = CGAffineTransform(a: -1.0, b: 0.0, c: 0.0, d: 1.0, tx: rect.size.width, ty: 0.0)
    
                context?.concatenate(transform)
    
                targetView.layer.render(in: context!)
    
                backImageView.image = UIGraphicsGetImageFromCurrentImageContext()
    
                UIGraphicsEndImageContext()
    
            }
    
        }
    
    平移

    平移效果的实现需要自定义动画效果, 具体实现可以查看 WLTranslationController
    在使用时,需要针对上一页,下一页数据做处理:

    /// 获取上一页控制器
    
        func getAboveReadViewController() ->UIViewController? {
    
            let recordModel = getPreviousModel(bookModel: bookModel)
    
            if recordModel == nil { return nil }
    
            return createCurrentReadController(bookModel: recordModel)
    
        }
    
        /// 获取下一页控制器
    
        func getBelowReadViewController() ->UIViewController? {
    
            
    
            let recordModel = getNextModel(bookModel: bookModel)
    
            
    
            if recordModel == nil { return nil }
    
            
    
            return createCurrentReadController(bookModel: recordModel)
    
        }
    
        
    
        func translationController(with translationController: WLTranslationController, controllerBefore controller: UIViewController) -> UIViewController? {
    
            readerMenu.showMenu(show: false)
    
            return getAboveReadViewController()
    
        }
    
        
    
        func translationController(with translationController: WLTranslationController, controllerAfter controller: UIViewController) -> UIViewController? {
    
            return getBelowReadViewController()
    
        }
    

    滚动

    滚动效果其实就是一个 tableView,需要动态计算每一页的渲染高度,设置给对应的cell,这里要注意的是在滚动过程中动态加载上一章和下一章

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
    
            if scrollPoint == nil { return }
    
            
    
            let point = scrollView.panGestureRecognizer.translation(in: scrollView)
    
            
    
            if point.y < scrollPoint.y { // 上滚
    
                
    
                isScrollUp = true
    
                getNextChapterPages()
    
                
    
            }else if point.y > scrollPoint.y { // 下滚
    
                
    
                isScrollUp = false
    
                getPreviousChapterPages()
    
            }
    
            // 记录坐标
    
            scrollPoint = point
    
        }
    

    覆盖,无效果

    覆盖和无效果的实现可以参看 WLReaderCoverController
    在阅读主页面中,需要实现对应的代理,加载上一页和下一页

    func coverGetPreviousController(coverController: WLReaderCoverController, currentController: UIViewController?) -> UIViewController? {
    
            return getAboveReadViewController()
    
        }
    
        func coverGetNextController(coverController: WLReaderCoverController, currentController: UIViewController?) -> UIViewController? {
    
            return getBelowReadViewController()
    
        }
    

    阅读的展示控制器是 WLReadViewController 它主要是对阅读展示视图的承载

    /// 初始化阅读视图
    
        private func createReadView() {
    
            let pageModel = chapterModel.pages[bookModel.pageIndex]
    
            let readView = WLReadView(frame: CGRectMake(0, WL_NAV_BAR_HEIGHT, view.bounds.width, WLBookConfig.shared.readContentRect.size.height))
    
            readView.pageModel = pageModel
    
            self.readView = readView
    
            view.addSubview(readView)
    
        }
    

    [图片上传失败...(image-208aeb-1717924572919)]

    渲染视图

    WLReadView

    private func addSubviews() {
    
            backgroundColor = .clear
    
            contentView = WLAttributedView(frame: bounds)
    
            contentView.shouldDrawImages = false
    
            contentView.shouldDrawLinks = true
    
            contentView.backgroundColor = .clear
    
            contentView.edgeInsets = UIEdgeInsets(top: 0, left: WLBookConfig.shared.readerEdget, bottom: 0, right: WLBookConfig.shared.readerEdget)
    
            addSubview(contentView)
    
        }
    

    它主要是承载富文本渲染视图

    WLAttributedView

    在渲染的时候用的是 DTCoreText,这里选用的是 DTAttributedLabelWLAttributedView 继承于DTAttributedLabel

    值得一提的是,在分页之后的显示,如果有段落首行缩进,每一页的第一行都会被认为是第一行,都有缩进,这里的处理如下:

    public var pageModel:WLBookPage! {
    
            didSet {
    
                contentView.attributedString = pageModel.content
    
                contentView.contentRange = pageModel.contentRange
    
                contentView.attributedString = pageModel.chapterContent
    
                var rect = contentView.bounds
    
                let insets = contentView.edgeInsets
    
                rect.origin.x    += insets.left;
    
                rect.origin.y    += insets.top;
    
                rect.size.width  -= (insets.left + insets.right);
    
                rect.size.height -= (insets.top  + insets.bottom);
    
                let layoutFrame = contentView.layouter.layoutFrame(with: rect, range: pageModel.contentRange)
    
                contentView.layoutFrame = layoutFrame
    
            }
    
        }
    

    笔者试过 YYLabel和原生的CoreText,也会存在这个问题,可以按照上述方式处理,比较合理的处理方式应该是:找到每一行数据,判断改行是否是段落首行,如果是,对其设置首行缩进,否则设置为0,有兴趣的可以自己尝试下

    另外就是长按事件也是在这个视图中处理的

    @objc func handleLongPressGesture(gesture: UILongPressGestureRecognizer) -> Void {
    
            let hitPoint = gesture.location(in: gesture.view)
    
            
    
            if gesture.state == .began {
    
                let hitIndex = self.closestCursorIndex(to: hitPoint)
    
                hitRange = self.locateParaRangeBy(index: hitIndex)
    
                selectedLineArray = self.lineArrayFrom(range: hitRange)
    
                self.setNeedsDisplay(bounds)
    
                showMagnifierView(point: hitPoint)
    
            }
    
            if gesture.state == .ended {
    
                tapGes = UITapGestureRecognizer.init(target: self, action: #selector(handleTapGes(gesture:)))
    
                self.addGestureRecognizer(tapGes)
    
                hideMagnifierView()
    
                showMenuItemView()
    
            }
    
            magnifierView?.locatePoint = hitPoint
    
            
    
        }
    

    由于逻辑比较复杂,处理的比较麻烦,具体的可以参照 demo

    下面简单看一下,在长按绘制选中颜色,左右游标,以及笔记划线的绘制

    private func drawSelectedLines(context: CGContext?) -> Void {
    
            if selectedLineArray.isEmpty {
    
                return
    
            }
    
            let path = CGMutablePath()
    
            for item in selectedLineArray {
    
                path.addRect(item)
    
            }
    
            let color = WL_READER_SELECTED_COLOR
    
            
    
            context?.setFillColor(color.cgColor)
    
            context?.addPath(path)
    
            context?.fillPath()
    
        }
    
    // MARK - 绘制左右游标
    
        private func drawLeftRightCursor(context:CGContext?) {
    
            if selectedLineArray.isEmpty {
    
                return
    
            }
    
            let firstRect = selectedLineArray.first!
    
            leftCursor = CGRect(x: firstRect.origin.x - 4, y: firstRect.origin.y, width: 4, height: firstRect.size.height)
    
            let lastRect = selectedLineArray.last!
    
            rightCursor = CGRect(x: lastRect.maxX, y: lastRect.origin.y, width: 4, height: lastRect.size.height)
    
            
    
            context?.addRect(leftCursor)
    
            context?.addRect(rightCursor)
    
            context?.addEllipse(in: CGRect(x: leftCursor.midX - 3, y: leftCursor.origin.y - 6, width: 6, height: 6))
    
            context?.addEllipse(in: CGRect(x: rightCursor.midX - 3, y: rightCursor.maxY, width: 6, height: 6))
    
            context?.setFillColor(WL_READER_CURSOR_COLOR.cgColor)
    
            context?.fillPath()
    
        }
    
    // MARK - 绘制虚线
    
        private func drawDash(context:CGContext?) {
    
            if noteArr.isEmpty {
    
                return
    
            }
    
            for item in noteArr {
    
                // 设置虚线样式
    
                let pattern: [CGFloat] = [5, 5]
    
                context?.setLineDash(phase: 0, lengths: pattern)
    
                context?.move(to: CGPointMake(item.origin.x, item.origin.y + item.height))
    
                context?.addLine(to: CGPointMake(item.origin.x + item.width, item.origin.y + item.height))
    
                    // 设置线条宽度和颜色
    
                context?.setLineWidth(2.0)
    
                context?.setStrokeColor(WL_READER_CURSOR_COLOR.cgColor)
    
                context?.strokePath()
    
            }
    
        }
    

    这里为什么要自己绘制下划线呢,笔者尝试过 DTCoreText 在富文本中设置下划线颜色的时候,显示时不生效,一直是默认的黑色,这个在大多数需求场景下是不符合要求的,所以需要自己绘制颜色

    图片显示,大图查看
    func attributedTextContentView(_ attributedTextContentView: DTAttributedTextContentView!, viewFor attachment: DTTextAttachment!, frame: CGRect) -> UIView! {
    
            if attachment.isKind(of: DTImageTextAttachment.self) {
    
                let imageView = DTLazyImageView()
    
                imageView.url = attachment.contentURL
    
                imageView.contentMode = .scaleAspectFit
    
                imageView.frame = frame
    
                imageView.isUserInteractionEnabled = true
    
                let tap = UITapGestureRecognizer(target: self, action: #selector(_onTapImage(tap:)))
    
                imageView.addGestureRecognizer(tap)
    
                return imageView
    
            }
    
            return nil
    
        }
    
        
    
        @objc private func _onTapImage(tap:UITapGestureRecognizer) {
    
            let imageView = tap.view as! DTLazyImageView
    
            let photoBrowser = WLReaderPhotoBroswer(frame: window!.bounds)
    
            let photoModel = WLReaderPhotoModel()
    
            photoModel.image = imageView.image
    
            photoBrowser.model = photoModel
    
            window?.addSubview(photoBrowser)
    
            photoBrowser.show()
    
        }
    

    DTCoreText 提供了代理回调,可以自己根据 attachment类型添加图片,以及对应的图片点击事件

    除此之外,还可以对链接点击进行处理:

    // MARK - 生成链接视图的代理
    
        func attributedTextContentView(_ attributedTextContentView: DTAttributedTextContentView!, viewForLink url: URL!, identifier: String!, frame: CGRect) -> UIView! {
    
            let btn = DTLinkButton(frame: frame)
    
            btn.url = url
    
            btn.alpha = 0.5
    
            btn.addTarget(self, action: #selector(_onTapBtn(btn:)), for: .touchUpInside)
    
            return btn
    
        }
    
        @objc private func _onTapBtn(btn:DTLinkButton) {
    
            
    
        }
    

    设置页面

    设置页面的内容比较多,主要包含:

    • 字号
    • 字体
    • 翻页类型
    • 行间距
    • 背景色更改

    下面主要介绍下 WLReaderMenu,这个是所有设置相关的页面入口控制

    public func showMenu(show:Bool) {
    
            if isMenuShow == show || !isAnimateComplete {
    
                return
    
            }
    
            isMenuShow = show
    
            isAnimateComplete = false
    
            if show {
    
                UIView.animate(withDuration: WL_READER_DEFAULT_ANIMATION_DURATION) {
    
                    self.topView.frame.origin.y = 0
    
                    self.bottomView.frame.origin.y = WL_SCREEN_HEIGHT - WL_READER_BOTTOM_HEIGHT
    
                } completion: { _ in
    
                    self.isAnimateComplete = true
    
                }
    
            }else {
    
                settingView.frame.origin.y = WL_SCREEN_HEIGHT + WL_READER_SETTING_HEIGHT
    
                UIView.animate(withDuration: WL_READER_DEFAULT_ANIMATION_DURATION) {
    
                    self.topView.frame.origin.y = -WL_NAV_BAR_HEIGHT
    
                    self.bottomView.frame.origin.y = WL_SCREEN_HEIGHT + WL_READER_BOTTOM_HEIGHT
    
                    self.fontTypeView.frame.origin.y = WL_SCREEN_HEIGHT + WL_READER_FONTTYPE_HEIGHT
    
                    self.effectView.frame.origin.y = WL_SCREEN_HEIGHT + WL_READER_EFFECTTYPE_HEIGHT
    
                    self.bgColorView.frame.origin.y = WL_SCREEN_HEIGHT + WL_READER_BACKGROUND_HEIGHT
    
                    self.noteView.frame.origin.y = WL_SCREEN_HEIGHT + WL_READER_NOTE_HEIGHT
    
                } completion: { _ in
    
                    self.isAnimateComplete = true
    
                }
    
            }
    
        }
    

    在阅读容器页面,只需要调用show方法,便可以对菜单栏相关的页面进行显示和隐藏

    在更改字体,背景色,字号,行间距,翻页方式之后,怎么刷新阅读器页面呢?

    func forceUpdateReader() {
    
            bookModel.chapters.forEach { item in
    
                item.forcePaging = true
    
            }
    
            createPageViewController(displayReadController: createCurrentReadController(bookModel: bookModel))
    
            // 刷新进度
    
            bookModel.pageIndex = WLBookConfig.shared.currentPageIndex
    
            createPageViewController(displayReadController: createCurrentReadController(bookModel: bookModel))
    
        }
    

    调用强制刷新阅读器方法即可,内部会对章节进行强制刷新标记,然后对章节会进行重新分页处理,重新构建对应的 page容器

    相关文章

      网友评论

          本文标题:iOS 小说阅读器-WLReader 介绍

          本文链接:https://www.haomeiwen.com/subject/hrovqjtx.html