一款完整的小說閱讀器功能包含:
- 閱讀主頁面的圖文混排
- 翻頁效果:仿真,平移消痛,滾動且叁,覆蓋,無效果
- 設(shè)置功能:字號更改秩伞,字體更改逞带,閱讀背景設(shè)置,亮度調(diào)整纱新,章節(jié)切換展氓,查看大圖,筆記劃線脸爱,書簽標記
- 閱讀記錄
- 網(wǎng)絡(luò)書籍下載遇汞,本地書籍解析
- 長按選中可復制和評論
- DEMO地址
錄屏效果 錄屏
小說閱讀器主要包含三個模塊:
- 解析:包含
txt
,epub
書籍解析,也包含網(wǎng)絡(luò)和本地書籍解析阅羹,最終生成章節(jié)富文本勺疼,分頁等處理 - 顯示:圖文混排钞馁,對解析出來的
html
文件或者txt
文件進行富文本展示處理 - 功能:包含翻頁辫封,字體施无,字號香追,背景色涧至,行間距調(diào)整贩绕,章節(jié)切換碑隆,書簽州袒,筆記看尼,閱讀記錄递鹉,數(shù)據(jù)庫保存等處理
先來說一說解析
CoreParser
解析的核心類,內(nèi)部分txt
, epub
解析
文件結(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
外界可使用的主要是 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
頁面展示
針對不同的翻頁效果立膛,做了不同的控制器分類
/// 默認閱讀主視圖
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
容器