一款完整的小说阅读器功能包含:
- 阅读主页面的图文混排
- 翻页效果:仿真,平移,滚动,覆盖,无效果
- 设置功能:字号更改,字体更改,阅读背景设置,亮度调整,章节切换,查看大图,笔记划线,书签标记
- 阅读记录
- 网络书籍下载,本地书籍解析
- 长按选中可复制和评论
- DEMO地址
录屏效果 录屏
小说阅读器主要包含三个模块:
- 解析:包含
txt
,epub
书籍解析,也包含网络和本地书籍解析,最终生成章节富文本,分页等处理 - 显示:图文混排,对解析出来的
html
文件或者txt
文件进行富文本展示处理 - 功能:包含翻页,字体,字号,背景色,行间距调整,章节切换,书签,笔记,阅读记录,数据库保存等处理
先来说一说解析
CoreParser
解析的核心类,内部分txt
, epub
解析
文件结构如上
核心代码如下:
// 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
外界可使用的主要是 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
页面展示
针对不同的翻页效果,做了不同的控制器分类
/// 默认阅读主视图
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
,这里选用的是 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
,也会存在这个问题,可以按照上述方式处理,比较合理的处理方式应该是:找到每一行数据,判断改行是否是段落首行,如果是,对其设置首行缩进,否则设置为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
容器