iOS 小说阅读器-WLReader 介绍

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

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容