iOS 小說閱讀器-WLReader 介紹

一款完整的小說閱讀器功能包含:

  • 閱讀主頁面的圖文混排
  • 翻頁效果:仿真,平移消痛,滾動且叁,覆蓋,無效果
  • 設(shè)置功能:字號更改秩伞,字體更改逞带,閱讀背景設(shè)置,亮度調(diào)整纱新,章節(jié)切換展氓,查看大圖,筆記劃線脸爱,書簽標記
  • 閱讀記錄
  • 網(wǎng)絡(luò)書籍下載遇汞,本地書籍解析
  • 長按選中可復制和評論
  • DEMO地址

錄屏效果 錄屏

背景色調(diào)整.png
筆記劃線.png
翻頁.png
設(shè)置.png
長按選中.png
主頁面.png
字體.png

小說閱讀器主要包含三個模塊:

  • 解析:包含txt, epub 書籍解析,也包含網(wǎng)絡(luò)和本地書籍解析阅羹,最終生成章節(jié)富文本勺疼,分頁等處理
  • 顯示:圖文混排钞馁,對解析出來的html文件或者txt文件進行富文本展示處理
  • 功能:包含翻頁辫封,字體施无,字號香追,背景色涧至,行間距調(diào)整贩绕,章節(jié)切換碑隆,書簽州袒,筆記看尼,閱讀記錄递鹉,數(shù)據(jù)庫保存等處理

先來說一說解析

CoreParser

解析的核心類,內(nèi)部分txt, epub解析

截屏2024-06-09 17.12.55.png

文件結(jié)構(gòu)如上

核心代碼如下:

// 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 解析

  • 解析章節(jié)藏斩,正則分割出對應(yīng)的章節(jié)標題
  • 章節(jié)分頁
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>()

            // 構(gòu)造讀書數(shù)據(jù)模型

            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

        }

    }

章節(jié)標題解析的正則方法:


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

    }

根據(jù)章節(jié)模型躏结,生成章節(jié)對應(yīng)的富文本

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! // 頁數(shù)

    var count: Int! // 字數(shù)

    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]!

}

它包含所有的章節(jié)數(shù)據(jù)模型,為了避免內(nèi)存浪費狰域,加快渲染速度媳拴,需要對章節(jié)的分頁進行按需加載黄橘,下面也會介紹到預(yù)加載效果,在展示當前章節(jié)時屈溉,對上一章和下一章進行提前分頁處理

epub 解析

借鑒了 FolioReaderKit 的解析方式塞关,具體的可以查看 Demo

閱讀器使用的model

截屏2024-06-09 17.13.36.png

外界可使用的主要是 WLBookModel

/// 目前使用文件名作為唯一ID,因為發(fā)現(xiàn)有的電子書沒有唯一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

    /// 所有的章節(jié)

    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!

    /// 包含筆記的章節(jié)

    public var chapterContainsNote:[WLBookNoteModel]! = [WLBookNoteModel]()

    /// 包含書簽的章節(jié)

    public var chapterContainsMark:[WLBookMarkModel]! = [WLBookMarkModel]()

它是非常重要的數(shù)據(jù)模型子巾,里面包含了書籍相關(guān)的所有信息

章節(jié)解析:

// MARK - 解析epub章節(jié)

     private func chapterFromEpub(epub: WLEpubBook) {

        // flatTableOfContents 代表有多少章節(jié)

        for (index, item) in epub.flatTableOfContents.enumerated() {

            // 創(chuàng)建章節(jié)數(shù)據(jù)

            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分章節(jié)

    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)

        }

    }

分頁時需要調(diào)用:

/// 對當前章節(jié)分頁

    public func paging(with currentChapterIndex:Int! = 0) {

        let chapter = self.chapters[safe: currentChapterIndex]

        chapter?.paging()

    }

章節(jié)數(shù)據(jù)模型

class WLBookChapter: NSObject {

    /// 章節(jié)標題

    var title:String!

    /// 是否隱藏

    var linear:Bool!

    /// 章節(jié)完整地址

    var fullHref:URL!

    /// 是否是一級標題

    var isFirstTitle:Bool! = false

    /// 當前章節(jié)分頁

    var pages:[WLBookPage]! = [WLBookPage]()

    /// 當前章節(jié)的所有內(nèi)容

    var chapterContentAttr:NSMutableAttributedString!

    /// 當前是第幾章

    var chapterIndex:Int! = 0

    /// 用于滾動模式

    var contentHeight:CGFloat! = 0

    /// 是否強制分頁,比如更改字體帆赢,字號,行間距等需要強制分頁线梗,默認是不需要的

    var forcePaging:Bool = false

}

分頁

在html加載顯示上椰于,我選用的是 DTCoreText 這個渲染庫,它對于html的渲染支持非常好缠导,在分頁時也是使用其布局相關(guān)的一些特性處理的廉羔,具體的操作如下:

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

這里需要注意下,在構(gòu)建富文本的時候僻造,如果遇到圖片等一些特殊節(jié)點憋他,需要提前在分頁之前進行一些特殊配置,比如圖片大小髓削,引用文本的樣式竹挡,標題的樣式處理等

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)

        }

    }

需要針對不同節(jié)點做不同處理,具體實現(xiàn)可以參照demo

頁面展示

截屏2024-06-09 17.14.17.png

針對不同的翻頁效果立膛,做了不同的控制器分類

/// 默認閱讀主視圖
    var readViewController:WLReadViewController!
    /// 滾動閱讀視圖
    var scrollReadController:WLReadScrollController!
    /// 閱讀對象
    var bookModel:WLBookModel!
    /// 翻頁控制器
    var pageController:WLReadPageController!
    /// 內(nèi)容容器揪罕,實際承載閱讀主視圖的容器視圖
    var container:WLContainerView!
    /// 用于區(qū)分仿真翻頁的正反面
    var pageCurlNumber:Int! = 1
    /// 平移控制器
    var translationController:WLTranslationController?
    /// 覆蓋控制器
    var coverController:WLReaderCoverController?
    /// 圖書路徑
    var bookPath:String!
    /// 圖書解析類
    var bookParser:WLBookParser!
    /// 閱讀菜單
    var readerMenu:WLReaderMenu!
    /// 章節(jié)列表
    var chapterListView:WLChapterListView!

閱讀器的主控制器是 WLReadContainer 它內(nèi)部包含了所有的翻頁控制器,章節(jié)列表宝泵,設(shè)置頁面等

外界調(diào)用方式:

@objc private func fastRead() {

        let path = Bundle.main.path(forResource: "張學良傳", ofType: "epub")

        let read = WLReadContainer()

        read.bookPath = path

        self.navigationController?.pushViewController(read, animated: true)

        

    }

只需要傳入對應(yīng)的書籍的path即可好啰,這里可以是本地書籍,也可以是網(wǎng)絡(luò)鏈接儿奶,內(nèi)部可以做網(wǎng)絡(luò)書籍下載處理

文件處理

/// 處理文件

    private func handleFile(_ path:String) {

        let exist = WLFileManager.fileExist(filePath: path)

        // 讀取配置

        WLBookConfig.shared.readDB()

        chapterListView.updateMainColor()

        if !exist { // 表明沒有下載并解壓過框往,需要先下載, 下載成功之后獲取下載的文件地址,進行解析

            downloadBook(path: path)

        }else {

            parseBook(path)

        }

    }
/// 根據(jù)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!

                // 需要從本地讀取之間的閱讀記錄闯捎,將對應(yīng)的章節(jié)和page的起始游標讀取出來椰弊,根據(jù)起始游標來算出是本章節(jié)的第幾頁

                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
/// 創(chuàng)建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()

    }

這個主要是創(chuàng)建閱讀的page容器

翻頁類型的數(shù)據(jù)層
  • 獲取上一頁數(shù)據(jù)
/// 獲取當前頁的上一頁數(shù)據(jù)

    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

    }
  • 獲取下一頁數(shù)據(jù)
/// 獲取當前頁的下一頁數(shù)據(jù)

    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

    }
  • 獲取當前展示數(shù)據(jù)的主頁面
/// 獲取當前閱讀的主頁面

    func createCurrentReadController(bookModel:WLBookModel!) -> WLReadViewController? {

        // 提前預(yù)加載下一章,上一章數(shù)據(jù)

        if bookModel == nil {

            return nil

        }

        // 刷新閱讀進度

        readerMenu.reloadReadProgress()

        // 刷新章節(jié)列表

        chapterListView.bookModel = bookModel

        // 如果不是滾動狀態(tài)茬祷,則需要提前預(yù)加載上一章與下一章內(nèi)容

        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

    }

在展示當前閱讀頁面數(shù)據(jù)的時候清焕,就可以對上一章和下一章的數(shù)據(jù)進行預(yù)加載處理,這里所說的預(yù)加載,其實就是對章節(jié)數(shù)據(jù)進行分頁

仿真翻頁

主要是對于翻頁上一頁和下一頁的容器加載和數(shù)據(jù)處理秸妥,具體的數(shù)據(jù)處理層在上述數(shù)據(jù)層做了說明

/// 上一頁

    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)

    }

如果想要仿真翻頁更加的真是借卧,在翻頁的時候背面應(yīng)該是當前頁的翻轉(zhuǎn)顯示,具體也就是上面所說的背面控制器筛峭,它就是對當前展示視圖的view做了一層旋轉(zhuǎn)

/// 獲取背面閱讀控制器,主要用于仿真翻頁

    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()

        }

    }
平移

平移效果的實現(xiàn)需要自定義動畫效果, 具體實現(xiàn)可以查看 WLTranslationController
在使用時陪每,需要針對上一頁影晓,下一頁數(shù)據(jù)做處理:

/// 獲取上一頁控制器

    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,需要動態(tài)計算每一頁的渲染高度檩禾,設(shè)置給對應(yīng)的cell挂签,這里要注意的是在滾動過程中動態(tài)加載上一章和下一章

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

    }

覆蓋,無效果

覆蓋和無效果的實現(xiàn)可以參看 WLReaderCoverController
在閱讀主頁面中盼产,需要實現(xiàn)對應(yīng)的代理饵婆,加載上一頁和下一頁

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,這里選用的是 DTAttributedLabel戏售,WLAttributedView 繼承于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,也會存在這個問題锋喜,可以按照上述方式處理些己,比較合理的處理方式應(yīng)該是:找到每一行數(shù)據(jù),判斷改行是否是段落首行嘿般,如果是段标,對其設(shè)置首行縮進,否則設(shè)置為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 {

            // 設(shè)置虛線樣式

            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))

                // 設(shè)置線條寬度和顏色

            context?.setLineWidth(2.0)

            context?.setStrokeColor(WL_READER_CURSOR_COLOR.cgColor)

            context?.strokePath()

        }

    }

這里為什么要自己繪制下劃線呢共耍,筆者嘗試過 DTCoreText 在富文本中設(shè)置下劃線顏色的時候虑灰,顯示時不生效,一直是默認的黑色痹兜,這個在大多數(shù)需求場景下是不符合要求的穆咐,所以需要自己繪制顏色

圖片顯示,大圖查看
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 提供了代理回調(diào),可以自己根據(jù) attachment類型添加圖片对湃,以及對應(yīng)的圖片點擊事件

除此之外崖叫,還可以對鏈接點擊進行處理:

// 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) {

        

    }

設(shè)置頁面

設(shè)置頁面的內(nèi)容比較多,主要包含:

  • 字號
  • 字體
  • 翻頁類型
  • 行間距
  • 背景色更改

下面主要介紹下 WLReaderMenu拍柒,這個是所有設(shè)置相關(guān)的頁面入口控制

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

            }

        }

    }

在閱讀容器頁面心傀,只需要調(diào)用show方法,便可以對菜單欄相關(guān)的頁面進行顯示和隱藏

在更改字體拆讯,背景色脂男,字號,行間距种呐,翻頁方式之后宰翅,怎么刷新閱讀器頁面呢?

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))

    }

調(diào)用強制刷新閱讀器方法即可爽室,內(nèi)部會對章節(jié)進行強制刷新標記汁讼,然后對章節(jié)會進行重新分頁處理,重新構(gòu)建對應(yīng)的 page容器

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末阔墩,一起剝皮案震驚了整個濱河市嘿架,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌戈擒,老刑警劉巖眶明,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異筐高,居然都是意外死亡搜囱,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進店門柑土,熙熙樓的掌柜王于貴愁眉苦臉地迎上來蜀肘,“玉大人,你說我怎么就攤上這事稽屏“绯瑁” “怎么了?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵狐榔,是天一觀的道長坛增。 經(jīng)常有香客問我,道長薄腻,這世上最難降的妖魔是什么收捣? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮庵楷,結(jié)果婚禮上罢艾,老公的妹妹穿的比我還像新娘楣颠。我一直安慰自己,他們只是感情好咐蚯,可當我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布童漩。 她就那樣靜靜地躺著,像睡著了一般春锋。 火紅的嫁衣襯著肌膚如雪矫膨。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天期奔,我揣著相機與錄音豆拨,去河邊找鬼。 笑死能庆,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的脚线。 我是一名探鬼主播搁胆,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼邮绿!你這毒婦竟也來了渠旁?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤船逮,失蹤者是張志新(化名)和其女友劉穎顾腊,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體挖胃,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡杂靶,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了酱鸭。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片吗垮。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖凹髓,靈堂內(nèi)的尸體忽然破棺而出烁登,到底是詐尸還是另有隱情,我是刑警寧澤蔚舀,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布饵沧,位于F島的核電站,受9級特大地震影響赌躺,放射性物質(zhì)發(fā)生泄漏狼牺。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一寿谴、第九天 我趴在偏房一處隱蔽的房頂上張望锁右。 院中可真熱鬧,春花似錦、人聲如沸咏瑟。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽码泞。三九已至兄旬,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間余寥,已是汗流浹背领铐。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留宋舷,地道東北人绪撵。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓,卻偏偏與公主長得像祝蝠,于是被迫代替她去往敵國和親音诈。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,979評論 2 355

推薦閱讀更多精彩內(nèi)容